feat: gap closure sprint — dark theme, @-mention, LocalComputerUse, tests

P0: U4 UsageStore + U5 CascadeStateStore independent test files (57 tests)
P1: Dark theme — tokens.css [data-theme="dark"] + theme.ts Pinia store
    + TopNav toggle button + App.vue dynamic Ant Design theme
P1: @-mention — MentionDropdown.vue + /skills/mention-suggest API
    + ChatInput integration with @ detection
P2: LocalComputerUseSession — pyautogui + screencapture (replaces Docker stub)
P2: Integration tests for gap closure (12 tests)
Fix: create_cascade_state_store() now passes session_ttl to InMemory fallback
This commit is contained in:
chiguyong 2026-06-14 16:16:50 +08:00
parent 0ccef7be5c
commit 6e0e081f23
12 changed files with 1424 additions and 69 deletions

View File

@ -241,5 +241,4 @@ def create_cascade_state_store(
return RedisCascadeStateStore(redis_url=redis_url, session_ttl=session_ttl)
except ImportError:
logger.warning("redis package not available, falling back to in-memory cascade store")
return InMemoryCascadeStateStore()
return InMemoryCascadeStateStore()
return InMemoryCascadeStateStore(session_ttl=session_ttl)

View File

@ -1,5 +1,5 @@
<template>
<a-config-provider :locale="zhCN" :theme="themeConfig">
<a-config-provider :locale="zhCN" :theme="themeStore.antThemeConfig.value">
<SplashScreen v-if="loading" :status="loadingStatus" :error="loadError" />
<router-view v-else />
</a-config-provider>
@ -9,11 +9,13 @@
import { ref, onMounted } from 'vue'
import { ConfigProvider as AConfigProvider } from 'ant-design-vue'
import zhCN from 'ant-design-vue/es/locale/zh_CN'
import { themeConfig } from './styles'
import { useThemeStore } from './stores/theme'
import { isTauri, startBackend, checkBackendHealth } from './api/tauri'
import { initApiBaseURL } from './api/base'
import SplashScreen from './components/layout/SplashScreen.vue'
const themeStore = useThemeStore()
const loading = ref(isTauri())
const loadingStatus = ref('正在初始化...')
const loadError = ref('')

View File

@ -1,5 +1,5 @@
<template>
<div class="chat-input">
<div class="chat-input" style="position: relative;">
<div v-if="contextPills.length > 0" class="chat-input__pills">
<ContextPill
v-for="(pill, idx) in contextPills"
@ -10,13 +10,23 @@
@remove="removePill(idx)"
/>
</div>
<MentionDropdown
:visible="mentionVisible"
:query="mentionQuery"
:skills="skillSuggestions"
:position="mentionPosition"
@select="insertMention"
@close="closeMention"
/>
<div class="chat-input__row">
<a-textarea
ref="textareaRef"
v-model:value="inputText"
:placeholder="placeholder"
:auto-size="{ minRows: 1, maxRows: 4 }"
:disabled="disabled"
@pressEnter="handlePressEnter"
@input="handleInput"
class="chat-input__textarea"
/>
<a-button
@ -37,6 +47,8 @@ import { ref, computed, type Component } from 'vue'
import { Input as AInput, Button as AButton } from 'ant-design-vue'
import { SendOutlined } from '@ant-design/icons-vue'
import ContextPill from './ContextPill.vue'
import MentionDropdown from './MentionDropdown.vue'
import { useSkillsStore } from '@/stores/skills'
const ATextarea = AInput.TextArea
@ -46,6 +58,11 @@ interface ContextPillData {
removable?: boolean
}
interface SkillSuggestion {
name: string
description: string
}
interface IProps {
disabled?: boolean
placeholder?: string
@ -62,19 +79,86 @@ const emit = defineEmits<{
const inputText = ref('')
const contextPills = ref<ContextPillData[]>([])
const textareaRef = ref<InstanceType<typeof ATextarea> | null>(null)
// @-mention state
const mentionVisible = ref(false)
const mentionQuery = ref('')
const mentionStartIndex = ref(-1)
const mentionPosition = ref({ left: 0 })
const skillsStore = useSkillsStore()
const skillSuggestions = computed<SkillSuggestion[]>(() => {
return (skillsStore.skills || []).map((s: any) => ({
name: s.name,
description: s.description || '',
}))
})
const canSend = computed(() => {
return inputText.value.trim().length > 0 && !props.disabled
})
function handleInput(): void {
detectMention()
}
function detectMention(): void {
const text = inputText.value
const cursorPos = text.length // textarea cursor at end for v-model
// Find the last @ that starts a potential mention
const lastAtIndex = text.lastIndexOf('@', cursorPos)
if (lastAtIndex === -1) {
closeMention()
return
}
// Check if there's a space between @ and cursor (mention is complete)
const textAfterAt = text.slice(lastAtIndex + 1, cursorPos)
if (textAfterAt.includes(' ') || textAfterAt.includes('\n')) {
closeMention()
return
}
// Check if @ is at start or preceded by whitespace
if (lastAtIndex > 0 && !/\s/.test(text[lastAtIndex - 1])) {
closeMention()
return
}
mentionVisible.value = true
mentionQuery.value = textAfterAt
mentionStartIndex.value = lastAtIndex
// Estimate horizontal position based on character count
mentionPosition.value = { left: Math.min(lastAtIndex * 8, 200) }
}
function insertMention(skill: SkillSuggestion): void {
const text = inputText.value
const before = text.slice(0, mentionStartIndex.value)
const after = text.slice(mentionStartIndex.value + 1 + mentionQuery.value.length)
inputText.value = `${before}@${skill.name} ${after}`
closeMention()
}
function closeMention(): void {
mentionVisible.value = false
mentionQuery.value = ''
mentionStartIndex.value = -1
}
function handleSend(): void {
const message = inputText.value.trim()
if (!message) return
closeMention()
emit('send', message)
inputText.value = ''
setTimeout(() => { inputText.value = '' }, 0)
}
function handlePressEnter(event: KeyboardEvent): void {
if (mentionVisible.value) return // Let MentionDropdown handle Enter
if (event.shiftKey) return
event.preventDefault()
handleSend()
@ -89,9 +173,9 @@ function removePill(idx: number): void {
.chat-input {
display: flex;
flex-direction: column;
padding: var(--space-2) var(--space-3);
padding: var(--space-3) var(--space-4);
background: var(--bg-primary);
border-top: 1px solid var(--border-color);
border-top: 1px solid var(--border-color-split);
}
.chat-input__pills {
@ -105,6 +189,16 @@ function removePill(idx: number): void {
display: flex;
align-items: flex-end;
gap: var(--space-2);
background: var(--bg-primary);
border-radius: var(--radius-lg);
padding: var(--space-1) var(--space-2);
border: 1px solid var(--border-color);
transition: border-color var(--transition-fast), box-shadow var(--transition-fast);
}
.chat-input__row:focus-within {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-light);
}
.chat-input__textarea {
@ -117,11 +211,34 @@ function removePill(idx: number): void {
line-height: 1.5;
padding-top: 8px;
padding-bottom: 8px;
background: transparent;
border: none;
box-shadow: none !important;
font-size: var(--font-base);
}
.chat-input__textarea :deep(.ant-input:focus) {
box-shadow: none !important;
}
.chat-input__textarea :deep(.ant-input::placeholder) {
color: var(--text-placeholder);
}
.chat-input__send {
flex-shrink: 0;
height: 40px;
min-height: 40px;
border-radius: var(--radius-md) !important;
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.chat-input__send:not(:disabled):hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(99, 102, 241, 0.3);
}
.chat-input__send:not(:disabled):active {
transform: translateY(0);
}
</style>

View File

@ -0,0 +1,168 @@
<template>
<div v-if="visible && filteredSkills.length > 0" class="mention-dropdown" :style="positionStyle">
<div class="mention-dropdown__header">
<span class="mention-dropdown__title">技能</span>
<span class="mention-dropdown__hint">选择或继续输入筛选</span>
</div>
<div class="mention-dropdown__list">
<button
v-for="(skill, idx) in filteredSkills"
:key="skill.name"
class="mention-dropdown__item"
:class="{ 'mention-dropdown__item--active': idx === activeIndex }"
@click="selectSkill(skill)"
@mouseenter="activeIndex = idx"
>
<span class="mention-dropdown__item-name">{{ skill.name }}</span>
<span class="mention-dropdown__item-desc">{{ skill.description }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
interface SkillSuggestion {
name: string
description: string
}
interface IProps {
visible: boolean
query: string
skills: SkillSuggestion[]
position?: { top: number; left: number }
}
const props = withDefaults(defineProps<IProps>(), {
position: () => ({ top: 0, left: 0 }),
})
const emit = defineEmits<{
select: [skill: SkillSuggestion]
close: []
}>()
const activeIndex = ref(0)
const filteredSkills = computed(() => {
const q = props.query.toLowerCase()
if (!q) return props.skills.slice(0, 8)
return props.skills
.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q))
.slice(0, 8)
})
const positionStyle = computed(() => ({
position: 'absolute' as const,
bottom: `calc(100% + 4px)`,
left: `${props.position.left}px`,
}))
watch(() => props.query, () => {
activeIndex.value = 0
})
function selectSkill(skill: SkillSuggestion) {
emit('select', skill)
}
function handleKeyDown(e: KeyboardEvent) {
if (!props.visible || filteredSkills.value.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
activeIndex.value = (activeIndex.value + 1) % filteredSkills.value.length
} else if (e.key === 'ArrowUp') {
e.preventDefault()
activeIndex.value = (activeIndex.value - 1 + filteredSkills.value.length) % filteredSkills.value.length
} else if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
selectSkill(filteredSkills.value[activeIndex.value])
} else if (e.key === 'Escape') {
e.preventDefault()
emit('close')
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
})
</script>
<style scoped>
.mention-dropdown {
background: var(--bg-elevated);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
min-width: 280px;
max-width: 400px;
z-index: var(--z-dropdown);
overflow: hidden;
}
.mention-dropdown__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-color-split);
}
.mention-dropdown__title {
font-size: var(--font-xs);
font-weight: var(--font-weight-semibold);
color: var(--text-tertiary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.mention-dropdown__hint {
font-size: var(--font-xs);
color: var(--text-placeholder);
}
.mention-dropdown__list {
max-height: 240px;
overflow-y: auto;
padding: var(--space-1) 0;
}
.mention-dropdown__item {
display: flex;
flex-direction: column;
gap: 2px;
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
background: transparent;
cursor: pointer;
text-align: left;
transition: background var(--transition-fast);
}
.mention-dropdown__item:hover,
.mention-dropdown__item--active {
background: var(--bg-tertiary);
}
.mention-dropdown__item-name {
font-size: var(--font-sm);
font-weight: var(--font-weight-medium);
color: var(--text-primary);
}
.mention-dropdown__item-desc {
font-size: var(--font-xs);
color: var(--text-tertiary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -1,6 +1,10 @@
<template>
<header class="top-nav">
<div class="top-nav__left">
<button class="top-nav__icon-btn" @click="emit('toggleIconNav')" title="切换导航">
<MenuFoldOutlined v-if="!iconNavCollapsed" />
<MenuUnfoldOutlined v-else />
</button>
<div class="top-nav__logo" @click="router.push('/agent')">
<span class="top-nav__logo-text">Fischer</span>
<span class="top-nav__logo-badge">AgentKit</span>
@ -17,6 +21,12 @@
:text="wsConnected ? '已连接' : '未连接'"
/>
</div>
<a-tooltip :title="isDark ? '切换亮色模式' : '切换暗色模式'">
<button class="top-nav__icon-btn" @click="themeStore.toggle()">
<BulbOutlined v-if="isDark" />
<BulbOutlined v-else style="opacity: 0.5" />
</button>
</a-tooltip>
<a-tooltip title="设置">
<button class="top-nav__icon-btn" @click="router.push('/agent/monitor?tab=settings')">
<SettingOutlined />
@ -30,12 +40,24 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { Badge as ABadge, Tooltip as ATooltip } from 'ant-design-vue'
import { SettingOutlined } from '@ant-design/icons-vue'
import { SettingOutlined, MenuFoldOutlined, MenuUnfoldOutlined, BulbOutlined } from '@ant-design/icons-vue'
import { useChatStore } from '@/stores/chat'
import { useThemeStore } from '@/stores/theme'
const props = defineProps<{
iconNavCollapsed?: boolean
}>()
const emit = defineEmits<{
toggleIconNav: []
}>()
const router = useRouter()
const chatStore = useChatStore()
const themeStore = useThemeStore()
const wsConnected = computed(() => chatStore.isWsConnected)
const iconNavCollapsed = computed(() => props.iconNavCollapsed ?? false)
const isDark = computed(() => themeStore.resolvedMode === 'dark')
</script>
<style scoped>
@ -44,11 +66,11 @@ const wsConnected = computed(() => chatStore.isWsConnected)
align-items: center;
justify-content: space-between;
height: var(--topnav-height);
padding: 0 var(--space-4);
padding: 0 var(--space-5);
background: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
border-bottom: 1px solid var(--border-color-split);
flex-shrink: 0;
z-index: var(--z-sticky);
z-index: 100;
}
.top-nav__left {
@ -63,6 +85,13 @@ const wsConnected = computed(() => chatStore.isWsConnected)
gap: var(--space-2);
cursor: pointer;
user-select: none;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-md);
transition: background var(--transition-fast);
}
.top-nav__logo:hover {
background: var(--bg-tertiary);
}
.top-nav__logo-text {
@ -79,9 +108,9 @@ const wsConnected = computed(() => chatStore.isWsConnected)
font-weight: var(--font-weight-medium);
color: var(--text-inverse);
background: var(--gradient-brand);
padding: 1px var(--space-2);
padding: 2px var(--space-2);
border-radius: var(--radius-full);
letter-spacing: 0.5px;
letter-spacing: 0.3px;
}
.top-nav__center {
@ -96,11 +125,14 @@ const wsConnected = computed(() => chatStore.isWsConnected)
.top-nav__right {
display: flex;
align-items: center;
gap: var(--space-3);
gap: var(--space-2);
}
.top-nav__status {
font-size: var(--font-xs);
padding: var(--space-1) var(--space-2);
border-radius: var(--radius-full);
background: var(--bg-tertiary);
}
.top-nav__status :deep(.ant-badge-status-text) {
@ -112,8 +144,8 @@ const wsConnected = computed(() => chatStore.isWsConnected)
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
width: 34px;
height: 34px;
border: none;
background: transparent;
color: var(--text-tertiary);

View File

@ -0,0 +1,143 @@
/**
* Theme Store Dark mode toggle with system preference detection.
*
* Persists preference to localStorage and syncs with the `data-theme`
* attribute on <html>. Also provides a reactive Ant Design theme config
* that updates when the mode changes.
*/
import { ref, computed, watchEffect } from 'vue'
import { defineStore } from 'pinia'
import type { ThemeConfig } from 'ant-design-vue/es/config-provider/context'
export type ThemeMode = 'light' | 'dark' | 'system'
const STORAGE_KEY = 'agentkit-theme-mode'
function readToken(varName: string, fallback: string): string {
if (typeof document === 'undefined') return fallback
const val = getComputedStyle(document.documentElement).getPropertyValue(varName).trim()
return val || fallback
}
function getSystemPreference(): 'light' | 'dark' {
if (typeof window === 'undefined') return 'light'
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function getStoredMode(): ThemeMode {
if (typeof localStorage === 'undefined') return 'system'
return (localStorage.getItem(STORAGE_KEY) as ThemeMode) || 'system'
}
function applyTheme(resolved: 'light' | 'dark') {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-theme', resolved)
}
export const useThemeStore = defineStore('theme', () => {
const mode = ref<ThemeMode>(getStoredMode())
const resolvedMode = computed<'light' | 'dark'>(() => {
if (mode.value === 'system') return getSystemPreference()
return mode.value
})
function setMode(newMode: ThemeMode) {
mode.value = newMode
localStorage.setItem(STORAGE_KEY, newMode)
}
function toggle() {
if (resolvedMode.value === 'light') {
setMode('dark')
} else {
setMode('light')
}
}
// Apply theme whenever resolvedMode changes
watchEffect(() => {
applyTheme(resolvedMode.value)
})
// Listen for system preference changes
if (typeof window !== 'undefined') {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
// Force re-evaluation when system preference changes
if (mode.value === 'system') {
applyTheme(getSystemPreference())
}
})
}
// Ant Design Vue theme config — reactive to dark mode
const antThemeConfig = computed<ThemeConfig>(() => {
const isDark = resolvedMode.value === 'dark'
return {
algorithm: isDark ? undefined : undefined, // Ant Design 4.x uses token-based theming
token: {
colorPrimary: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
colorInfo: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
colorSuccess: readToken('--color-success', isDark ? '#4ade80' : '#22c55e'),
colorWarning: readToken('--color-warning', isDark ? '#fbbf24' : '#f59e0b'),
colorError: readToken('--color-error', isDark ? '#f87171' : '#ef4444'),
colorText: readToken('--text-primary', isDark ? '#fbfbfa' : '#1a1a1a'),
colorTextSecondary: readToken('--text-secondary', isDark ? '#cececd' : '#4a4a4a'),
colorTextTertiary: readToken('--text-tertiary', isDark ? '#9b9b9a' : '#6b6b6a'),
colorTextQuaternary: readToken('--text-placeholder', isDark ? '#6b6b6a' : '#9b9b9a'),
colorBgContainer: readToken('--bg-primary', isDark ? '#1a1a1a' : '#ffffff'),
colorBgLayout: readToken('--bg-secondary', isDark ? '#1f1f1f' : '#fbfbfa'),
colorBgElevated: readToken('--bg-elevated', isDark ? '#252525' : '#ffffff'),
colorBorder: readToken('--border-color', isDark ? '#3a3a3a' : '#ededec'),
colorBorderSecondary: readToken('--border-color-split', isDark ? '#2f2f2f' : '#f2f2f0'),
fontSize: 14,
fontSizeSM: 12,
fontSizeLG: 16,
fontSizeXL: 20,
borderRadius: 8,
borderRadiusSM: 6,
borderRadiusLG: 12,
controlHeight: 32,
controlHeightSM: 24,
controlHeightLG: 40,
boxShadow: isDark
? '0 1px 2px rgba(0, 0, 0, 0.2)'
: '0 1px 2px rgba(0, 0, 0, 0.04)',
boxShadowSecondary: isDark
? '0 2px 8px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15)'
: '0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04)',
},
components: {
Menu: {
itemSelectedBg: readToken('--color-primary-light', isDark ? '#1e1b4b' : '#eef2ff'),
itemSelectedColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
itemHoverBg: isDark ? '#1e1b4b' : '#f5f3ff',
itemHoverColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
itemColor: readToken('--text-secondary', isDark ? '#cececd' : '#4a4a4a'),
} as Record<string, unknown>,
Tabs: {
itemSelectedColor: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
itemHoverColor: readToken('--color-primary-hover', isDark ? '#6366f1' : '#4f46e5'),
} as Record<string, unknown>,
Select: {
colorPrimary: readToken('--color-primary', isDark ? '#818cf8' : '#6366f1'),
colorPrimaryHover: readToken('--color-primary-hover', isDark ? '#6366f1' : '#4f46e5'),
} as Record<string, unknown>,
Button: { borderRadius: 8, controlHeight: 32 } as Record<string, unknown>,
Card: { borderRadiusLG: 10 } as Record<string, unknown>,
Input: { borderRadius: 8 } as Record<string, unknown>,
Modal: { borderRadiusLG: 12 } as Record<string, unknown>,
},
}
})
return {
mode,
resolvedMode,
antThemeConfig,
setMode,
toggle,
}
})

View File

@ -1,63 +1,70 @@
/**
* Fischer AgentKit Design Tokens
* Fischer AgentKit Design Tokens Notion-Inspired Light Theme
*
* CSS Custom Properties as the single source of truth.
* Ant Design Vue theme tokens are mapped from these values in theme.ts.
*
* Design Philosophy:
* - Warm neutrals (Notion-style) instead of cold grays
* - Softer shadows with more spread, less offset
* - Generous border-radius for friendly feel
* - Subtle borders that fade into background
* - Refined hover/active states with gentle transitions
*/
:root {
/* ── Brand Colors ── */
--color-primary: #7c3aed;
--color-primary-hover: #6d28d9;
--color-primary-active: #5b21b6;
--color-primary-light: #ede9fe;
--color-primary: #6366f1;
--color-primary-hover: #4f46e5;
--color-primary-active: #4338ca;
--color-primary-light: #eef2ff;
--color-primary-bg: #f5f3ff;
/* ── Gradient ── */
--gradient-brand: linear-gradient(135deg, #7c3aed 0%, #1e1b4b 100%);
--gradient-brand: linear-gradient(135deg, #6366f1 0%, #312e81 100%);
/* ── Semantic Colors ── */
--color-success: #10b981;
--color-success-light: #d1fae5;
--color-success: #22c55e;
--color-success-light: #dcfce7;
--color-warning: #f59e0b;
--color-warning-light: #fef3c7;
--color-warning-light: #fef9c3;
--color-error: #ef4444;
--color-error-light: #fee2e2;
--color-info: #3b82f6;
--color-info-light: #dbeafe;
/* ── Neutral / Gray Scale ── */
--color-gray-50: #fafafa;
--color-gray-100: #f5f5f5;
--color-gray-200: #e5e5e5;
--color-gray-300: #d4d4d4;
--color-gray-400: #a3a3a3;
--color-gray-500: #737373;
--color-gray-600: #525252;
--color-gray-700: #404040;
--color-gray-800: #262626;
--color-gray-900: #171717;
/* ── Neutral / Warm Gray Scale (Notion-inspired) ── */
--color-gray-50: #fbfbfa;
--color-gray-100: #f7f7f5;
--color-gray-200: #ededec;
--color-gray-300: #dfdfde;
--color-gray-400: #cececd;
--color-gray-500: #9b9b9a;
--color-gray-600: #6b6b6a;
--color-gray-700: #4a4a4a;
--color-gray-800: #2f2f2f;
--color-gray-900: #1a1a1a;
/* ── Background ── */
--bg-primary: #ffffff;
--bg-secondary: #fafafa;
--bg-tertiary: #f5f5f5;
--bg-secondary: #fbfbfa;
--bg-tertiary: #f7f7f5;
--bg-elevated: #ffffff;
--bg-code: #282c34;
--bg-code: #1e1e2e;
/* ── Foreground / Text ── */
--text-primary: #171717;
--text-secondary: #525252;
--text-tertiary: #737373;
--text-placeholder: #a3a3a3;
--text-primary: #1a1a1a;
--text-secondary: #4a4a4a;
--text-tertiary: #6b6b6a;
--text-placeholder: #9b9b9a;
--text-inverse: #ffffff;
--text-code: #abb2bf;
--text-code: #cdd6f4;
/* ── Border ── */
--border-color: #e5e5e5;
--border-color-hover: #d4d4d4;
--border-color: #ededec;
--border-color-hover: #dfdfde;
--border-color-active: var(--color-primary);
--border-color-split: #f0f0f0;
--border-color-split: #f2f2f0;
/* ── Spacing ── */
--space-1: 4px;
@ -73,8 +80,8 @@
/* ── Border Radius ── */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-lg: 10px;
--radius-xl: 14px;
--radius-full: 9999px;
/* ── Font Size ── */
@ -96,11 +103,11 @@
--leading-normal: 1.5;
--leading-relaxed: 1.75;
/* ── Shadow ── */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -2px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
/* ── Shadow (Notion-style: softer, more ambient) ── */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.06), 0 2px 4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.08), 0 2px 8px rgba(0, 0, 0, 0.04);
/* ── Transition ── */
--transition-fast: 150ms ease;
@ -121,16 +128,91 @@
--sidebar-width: 280px;
--quadrant-min-size: 320px;
/* ── Code Theme (One Dark Pro) ── */
--code-bg: #282c34;
--code-fg: #abb2bf;
--code-keyword: #c678dd;
--code-string: #98c379;
--code-number: #d19a66;
--code-comment: #5c6370;
--code-function: #61afef;
--code-variable: #e06c75;
--code-type: #e5c07b;
--code-added-bg: rgba(16, 185, 129, 0.15);
--code-removed-bg: rgba(239, 68, 68, 0.15);
/* ── Code Theme (Catppuccin Mocha — warmer dark theme) ── */
--code-bg: #1e1e2e;
--code-fg: #cdd6f4;
--code-keyword: #cba6f7;
--code-string: #a6e3a1;
--code-number: #fab387;
--code-comment: #6c7086;
--code-function: #89b4fa;
--code-variable: #f38ba8;
--code-type: #f9e2af;
--code-added-bg: rgba(166, 227, 161, 0.15);
--code-removed-bg: rgba(243, 139, 168, 0.15);
}
/* ── Dark Theme ── */
[data-theme="dark"] {
/* ── Brand Colors ── */
--color-primary: #818cf8;
--color-primary-hover: #6366f1;
--color-primary-active: #4f46e5;
--color-primary-light: #1e1b4b;
--color-primary-bg: #1e1b4b;
/* ── Gradient ── */
--gradient-brand: linear-gradient(135deg, #818cf8 0%, #4338ca 100%);
/* ── Semantic Colors ── */
--color-success: #4ade80;
--color-success-light: #14532d;
--color-warning: #fbbf24;
--color-warning-light: #422006;
--color-error: #f87171;
--color-error-light: #450a0a;
--color-info: #60a5fa;
--color-info-light: #172554;
/* ── Neutral / Warm Gray Scale (Dark) ── */
--color-gray-50: #1a1a1a;
--color-gray-100: #2f2f2f;
--color-gray-200: #3a3a3a;
--color-gray-300: #4a4a4a;
--color-gray-400: #6b6b6a;
--color-gray-500: #9b9b9a;
--color-gray-600: #b0b0af;
--color-gray-700: #cececd;
--color-gray-800: #ededec;
--color-gray-900: #fbfbfa;
/* ── Background ── */
--bg-primary: #1a1a1a;
--bg-secondary: #1f1f1f;
--bg-tertiary: #2a2a2a;
--bg-elevated: #252525;
--bg-code: #11111b;
/* ── Foreground / Text ── */
--text-primary: #fbfbfa;
--text-secondary: #cececd;
--text-tertiary: #9b9b9a;
--text-placeholder: #6b6b6a;
--text-inverse: #1a1a1a;
--text-code: #cdd6f4;
/* ── Border ── */
--border-color: #3a3a3a;
--border-color-hover: #4a4a4a;
--border-color-active: var(--color-primary);
--border-color-split: #2f2f2f;
/* ── Shadow (darker ambient for dark mode) ── */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 2px 8px rgba(0, 0, 0, 0.25), 0 1px 2px rgba(0, 0, 0, 0.15);
--shadow-lg: 0 4px 16px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.15);
--shadow-xl: 0 8px 32px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.15);
/* ── Code Theme (Catppuccin Mocha — unchanged, already dark) ── */
--code-bg: #11111b;
--code-fg: #cdd6f4;
--code-keyword: #cba6f7;
--code-string: #a6e3a1;
--code-number: #fab387;
--code-comment: #6c7086;
--code-function: #89b4fa;
--code-variable: #f38ba8;
--code-type: #f9e2af;
--code-added-bg: rgba(166, 227, 161, 0.2);
--code-removed-bg: rgba(243, 139, 168, 0.2);
}

View File

@ -160,6 +160,30 @@ async def list_skills(req: Request):
]
@router.get("/skills/mention-suggest")
async def mention_suggest(q: str = "", req: Request = None):
"""Suggest skills for @-mention autocomplete.
Returns up to 8 skills matching the query string, with name and description
for the MentionDropdown component.
"""
skill_registry = req.app.state.skill_registry
skills = skill_registry.list_skills()
query = q.lower()
if query:
skills = [
s for s in skills
if query in s.name.lower()
or (s.config.description and query in s.config.description.lower())
]
return [
{"name": s.name, "description": s.config.description or ""}
for s in skills[:8]
]
@router.post("/skills/install")
async def install_skill(request: InstallSkillRequest, req: Request):
"""Search for and install a skill by name.

View File

@ -1,12 +1,16 @@
"""ComputerUseSession - 虚拟桌面会话管理
管理虚拟桌面会话Docker 沙箱维护操作上下文
提供 InMemoryComputerUseSession 用于测试DockerComputerUseSession 用于生产
管理虚拟桌面会话维护操作上下文
提供 InMemoryComputerUseSession 用于测试LocalComputerUseSession 用于本地桌面
DockerComputerUseSession 用于 Docker 沙箱stub
"""
from __future__ import annotations
import base64
import io
import logging
import platform
import time
import uuid
from abc import ABC, abstractmethod
@ -248,6 +252,124 @@ class InMemoryComputerUseSession(ComputerUseSession):
)
class LocalComputerUseSession(ComputerUseSession):
"""本地桌面会话,使用 pyautogui + screencapture。
在本地桌面执行真实的 UI 操作需要辅助功能权限macOS
适用于开发/演示环境生产环境应使用 DockerComputerUseSession
"""
def __init__(
self,
session_id: str | None = None,
screen_width: int = 1280,
screen_height: int = 720,
):
super().__init__(
session_id=session_id,
screen_width=screen_width,
screen_height=screen_height,
)
self._pyautogui: Any = None
async def start(self) -> None:
"""启动本地桌面会话"""
try:
import pyautogui
self._pyautogui = pyautogui
pyautogui.FAILSAFE = True
pyautogui.PAUSE = 0.1
except ImportError:
raise RuntimeError(
"pyautogui is required for LocalComputerUseSession. "
"Install with: pip install pyautogui"
)
self._started = True
logger.info("Local desktop session started: %s", self.session_id[:8])
async def stop(self) -> None:
"""停止本地桌面会话"""
self._started = False
self._pyautogui = None
logger.info("Local desktop session stopped: %s", self.session_id[:8])
async def screenshot(self) -> ActionResult:
"""截取本地桌面屏幕"""
if not self._started:
return ActionResult(success=False, action="screenshot", error="Session not started")
try:
img = self._pyautogui.screenshot()
buf = io.BytesIO()
img.save(buf, format="PNG")
screenshot_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return ActionResult(
success=True,
action="screenshot",
output=f"Screenshot {img.size[0]}x{img.size[1]}",
screenshot_base64=screenshot_b64,
)
except Exception as e:
return ActionResult(success=False, action="screenshot", error=str(e))
async def execute_action(self, action: str, **params: Any) -> ActionResult:
"""在本地桌面执行 UI 操作"""
if not self._started:
return ActionResult(success=False, action=action, error="Session not started")
try:
result = self._execute_local_action(action, **params)
self.record_action(action, params, result)
return result
except Exception as e:
return ActionResult(success=False, action=action, error=str(e))
def _execute_local_action(self, action: str, **params: Any) -> ActionResult:
"""执行本地 UI 操作"""
pg = self._pyautogui
if action == "click":
x, y = params.get("x", 0), params.get("y", 0)
button = params.get("button", "left")
pg.click(x, y, button=button)
return ActionResult(success=True, action="click", output=f"Clicked at ({x}, {y})")
if action == "type":
text = params.get("text", "")
pg.typewrite(text) if platform.system() != "Darwin" else pg.write(text)
return ActionResult(success=True, action="type", output=f"Typed: {text}")
if action == "scroll":
direction = params.get("direction", "down")
amount = params.get("amount", 3)
clicks = amount if direction == "down" else -amount
pg.scroll(clicks)
return ActionResult(success=True, action="scroll", output=f"Scrolled {direction} by {amount}")
if action == "drag":
sx, sy = params.get("start_x", 0), params.get("start_y", 0)
ex, ey = params.get("end_x", 0), params.get("end_y", 0)
pg.moveTo(sx, sy)
pg.dragTo(ex, ey, duration=0.5)
return ActionResult(success=True, action="drag", output=f"Dragged from ({sx},{sy}) to ({ex},{ey})")
if action == "key":
key_name = params.get("key_name", "")
keys = key_name.split("+")
if len(keys) > 1:
pg.hotkey(*keys)
else:
pg.press(key_name)
return ActionResult(success=True, action="key", output=f"Pressed key: {key_name}")
if action == "wait":
duration = params.get("duration", 1.0)
time.sleep(duration)
return ActionResult(success=True, action="wait", output=f"Waited {duration}s")
return ActionResult(success=False, action=action, error=f"Unknown action: {action}")
class DockerComputerUseSession(ComputerUseSession):
"""Docker 沙箱虚拟桌面会话

View File

@ -0,0 +1,163 @@
"""Gap closure integration tests — dark theme, @-mention API, LocalComputerUseSession."""
import pytest
from unittest.mock import MagicMock, patch
from agentkit.quality.cascade_state_store import (
InMemoryCascadeStateStore,
create_cascade_state_store,
)
from agentkit.llm.providers.usage_store import (
InMemoryUsageStore,
create_usage_store,
)
from agentkit.tools.computer_use_session import (
InMemoryComputerUseSession,
LocalComputerUseSession,
ComputerUseSessionManager,
)
# ---------------------------------------------------------------------------
# Dark theme: CSS token validation (smoke test)
# ---------------------------------------------------------------------------
class TestDarkThemeTokens:
"""Verify dark theme CSS tokens exist in tokens.css."""
def test_dark_theme_tokens_file_contains_dark_selector(self):
import pathlib
tokens_path = pathlib.Path(__file__).parent.parent.parent / (
"src/agentkit/server/frontend/src/styles/tokens.css"
)
if not tokens_path.exists():
pytest.skip("Frontend tokens.css not found")
content = tokens_path.read_text()
assert '[data-theme="dark"]' in content
assert '--bg-primary: #1a1a1a' in content
assert '--text-primary: #fbfbfa' in content
assert '--border-color: #3a3a3a' in content
def test_theme_store_exists(self):
import pathlib
store_path = pathlib.Path(__file__).parent.parent.parent / (
"src/agentkit/server/frontend/src/stores/theme.ts"
)
if not store_path.exists():
pytest.skip("Theme store not found")
content = store_path.read_text()
assert 'useThemeStore' in content
assert 'toggle' in content
assert 'resolvedMode' in content
assert 'localStorage' in content
# ---------------------------------------------------------------------------
# @-mention API: mention-suggest endpoint
# ---------------------------------------------------------------------------
class TestMentionSuggestAPI:
"""Test the /skills/mention-suggest endpoint logic."""
def _make_skill(self, name, description):
skill = MagicMock()
skill.name = name
skill.config.description = description
return skill
def test_mention_suggest_filters_by_name(self):
"""Verify the filtering logic used by the endpoint."""
skills = [
self._make_skill("geo_pipeline", "GEO pipeline skill"),
self._make_skill("code_reviewer", "Code review skill"),
self._make_skill("data_analyst", "Data analysis skill"),
]
query = "geo"
filtered = [
s for s in skills
if query in s.name.lower()
or (s.config.description and query in s.config.description.lower())
]
assert len(filtered) == 1
assert filtered[0].name == "geo_pipeline"
def test_mention_suggest_filters_by_description(self):
skills = [
self._make_skill("skill_a", "GEO pipeline skill"),
self._make_skill("skill_b", "Code review skill"),
]
query = "review"
filtered = [
s for s in skills
if query in s.name.lower()
or (s.config.description and query in s.config.description.lower())
]
assert len(filtered) == 1
assert filtered[0].name == "skill_b"
def test_mention_suggest_limits_to_8(self):
skills = [self._make_skill(f"skill_{i}", "") for i in range(20)]
result = skills[:8]
assert len(result) == 8
def test_mention_suggest_empty_query(self):
skills = [
self._make_skill("skill_a", "desc"),
self._make_skill("skill_b", "desc"),
]
query = ""
filtered = [
s for s in skills
if query in s.name.lower()
or (s.config.description and query in s.config.description.lower())
]
assert len(filtered) == 2
# ---------------------------------------------------------------------------
# LocalComputerUseSession
# ---------------------------------------------------------------------------
class TestLocalComputerUseSession:
"""Test LocalComputerUseSession (without actual pyautogui)."""
def test_inherits_from_base(self):
assert issubclass(LocalComputerUseSession, InMemoryComputerUseSession.__mro__[1])
def test_start_without_pyautogui_raises(self):
"""If pyautogui is not installed, start should raise RuntimeError."""
import asyncio
session = LocalComputerUseSession()
with patch.dict("sys.modules", {"pyautogui": None}):
with pytest.raises(RuntimeError, match="pyautogui"):
asyncio.run(session.start())
def test_session_manager_with_local_factory(self):
manager = ComputerUseSessionManager(session_factory=LocalComputerUseSession)
session = manager.get_or_create(session_id="test-local")
assert isinstance(session, LocalComputerUseSession)
# ---------------------------------------------------------------------------
# Factory session_ttl propagation (regression test)
# ---------------------------------------------------------------------------
class TestFactorySessionTTL:
"""Ensure session_ttl is propagated through factories."""
def test_cascade_store_memory_with_custom_ttl(self):
store = create_cascade_state_store(backend="memory", session_ttl=7200)
assert isinstance(store, InMemoryCascadeStateStore)
assert store._session_ttl == 7200
def test_usage_store_memory_backend(self):
store = create_usage_store(backend="memory")
assert isinstance(store, InMemoryUsageStore)
def test_cascade_store_auto_backend(self):
store = create_cascade_state_store(backend="auto")
assert store._session_ttl == 86400 # default

View File

@ -0,0 +1,229 @@
"""Unit tests for UsageStore (U4 — UsageStore Persistence)."""
import pytest
from datetime import datetime, timedelta, timezone
from agentkit.llm.protocol import TokenUsage
from agentkit.llm.providers.usage_store import (
InMemoryUsageStore,
RedisUsageStore,
UsageRecord,
UsageBucket,
UsageSummary,
UsageStore,
create_usage_store,
)
# ---------------------------------------------------------------------------
# InMemoryUsageStore
# ---------------------------------------------------------------------------
class TestInMemoryUsageStore:
"""Tests for InMemoryUsageStore."""
def test_implements_protocol(self):
store = InMemoryUsageStore()
assert isinstance(store, UsageStore)
def test_record_single(self):
store = InMemoryUsageStore()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
summary = store.get_usage()
assert summary.total_tokens == 150
assert summary.total_cost == 0.05
assert len(summary.records) == 1
def test_record_multiple(self):
store = InMemoryUsageStore()
usage1 = TokenUsage(prompt_tokens=100, completion_tokens=50)
usage2 = TokenUsage(prompt_tokens=200, completion_tokens=100)
store.record("agent1", "gpt-4", usage1, cost=0.05, latency_ms=200)
store.record("agent1", "gpt-4", usage2, cost=0.10, latency_ms=300)
summary = store.get_usage()
assert summary.total_tokens == 450
assert abs(summary.total_cost - 0.15) < 1e-6
assert len(summary.records) == 2
def test_get_usage_by_agent(self):
store = InMemoryUsageStore()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
store.record("agent2", "gpt-4", usage, cost=0.05, latency_ms=200)
summary = store.get_usage(agent_name="agent1")
assert len(summary.records) == 1
assert summary.records[0].agent_name == "agent1"
def test_get_usage_by_time_range(self):
store = InMemoryUsageStore()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
# Query with start_time in the future — should return empty
future = datetime.now(timezone.utc) + timedelta(hours=1)
summary = store.get_usage(start_time=future)
assert len(summary.records) == 0
# Query with end_time in the past — should return empty
past = datetime.now(timezone.utc) - timedelta(hours=1)
summary = store.get_usage(end_time=past)
assert len(summary.records) == 0
# Query with wide range — should return the record
start = datetime.now(timezone.utc) - timedelta(hours=1)
end = datetime.now(timezone.utc) + timedelta(hours=1)
summary = store.get_usage(start_time=start, end_time=end)
assert len(summary.records) == 1
def test_get_usage_by_model(self):
store = InMemoryUsageStore()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
store.record("agent1", "gpt-3.5", usage, cost=0.01, latency_ms=100)
summary = store.get_usage()
assert "gpt-4" in summary.by_model
assert "gpt-3.5" in summary.by_model
assert summary.by_model["gpt-4"]["count"] == 1
assert summary.by_model["gpt-3.5"]["count"] == 1
def test_get_usage_empty(self):
store = InMemoryUsageStore()
summary = store.get_usage()
assert summary.total_tokens == 0
assert summary.total_cost == 0.0
assert len(summary.records) == 0
def test_max_records_trimming(self):
store = InMemoryUsageStore()
store.MAX_RECORDS = 5
usage = TokenUsage(prompt_tokens=1, completion_tokens=1)
for i in range(10):
store.record(f"agent{i}", "gpt-4", usage, cost=0.01, latency_ms=100)
assert len(store._records) == 5
# Should keep the last 5 records
assert store._records[0].agent_name == "agent5"
def test_usage_record_timestamp(self):
store = InMemoryUsageStore()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
rec = store.get_usage().records[0]
assert rec.timestamp != ""
# Should be parseable as ISO 8601
datetime.fromisoformat(rec.timestamp)
# ---------------------------------------------------------------------------
# UsageRecord / UsageBucket / UsageSummary dataclasses
# ---------------------------------------------------------------------------
class TestDataclasses:
def test_usage_record_auto_timestamp(self):
rec = UsageRecord(
agent_name="a", model="m",
prompt_tokens=1, completion_tokens=1,
total_tokens=2, cost=0.01, latency_ms=100,
)
assert rec.timestamp != ""
def test_usage_record_explicit_timestamp(self):
rec = UsageRecord(
agent_name="a", model="m",
prompt_tokens=1, completion_tokens=1,
total_tokens=2, cost=0.01, latency_ms=100,
timestamp="2026-01-01T00:00:00+00:00",
)
assert rec.timestamp == "2026-01-01T00:00:00+00:00"
def test_usage_bucket_defaults(self):
bucket = UsageBucket()
assert bucket.prompt_tokens == 0
assert bucket.completion_tokens == 0
assert bucket.total_tokens == 0
assert bucket.cost == 0.0
assert bucket.count == 0
def test_usage_summary_defaults(self):
summary = UsageSummary()
assert summary.total_tokens == 0
assert summary.total_cost == 0.0
assert summary.by_model == {}
assert summary.records == []
# ---------------------------------------------------------------------------
# RedisUsageStore (mocked)
# ---------------------------------------------------------------------------
class TestRedisUsageStoreMocked:
"""Tests for RedisUsageStore with mocked Redis."""
def _make_store(self):
store = RedisUsageStore(redis_url="redis://localhost:6379")
return store
def test_implements_protocol(self):
store = self._make_store()
assert isinstance(store, UsageStore)
def test_degrade_to_fallback(self):
store = self._make_store()
assert not store._degraded
store._degrade_to_fallback()
assert store._degraded
assert store._fallback is not None
def test_record_degraded_uses_fallback(self):
store = self._make_store()
store._degrade_to_fallback()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
# Should be in fallback
summary = store._fallback.get_usage()
assert len(summary.records) == 1
def test_get_usage_degraded_uses_fallback(self):
store = self._make_store()
store._degrade_to_fallback()
usage = TokenUsage(prompt_tokens=100, completion_tokens=50)
store._fallback.record("agent1", "gpt-4", usage, cost=0.05, latency_ms=200)
summary = store.get_usage()
assert len(summary.records) == 1
def test_get_usage_degraded_no_fallback_returns_empty(self):
store = self._make_store()
store._degraded = True
# No fallback set — should return empty
summary = store.get_usage()
assert summary.total_tokens == 0
def test_today_key_format(self):
store = self._make_store()
key = store._today_key()
# Should be YYYY-MM-DD
assert len(key) == 10
assert key[4] == "-"
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
class TestCreateUsageStore:
def test_memory_backend(self):
store = create_usage_store(backend="memory")
assert isinstance(store, InMemoryUsageStore)
def test_auto_backend_returns_store(self):
store = create_usage_store(backend="auto")
assert isinstance(store, (InMemoryUsageStore, RedisUsageStore))
def test_redis_backend_returns_store(self):
store = create_usage_store(backend="redis")
# May be InMemory if redis package unavailable
assert isinstance(store, (InMemoryUsageStore, RedisUsageStore))

View File

@ -0,0 +1,274 @@
"""Unit tests for CascadeStateStore (U5 — CascadeStateStore Persistence)."""
import pytest
import time
from unittest.mock import MagicMock, patch
from agentkit.quality.cascade_state_store import (
CascadeStateStore,
InMemoryCascadeStateStore,
RedisCascadeStateStore,
create_cascade_state_store,
)
# ---------------------------------------------------------------------------
# InMemoryCascadeStateStore
# ---------------------------------------------------------------------------
class TestInMemoryCascadeStateStore:
"""Tests for InMemoryCascadeStateStore."""
def test_implements_protocol(self):
store = InMemoryCascadeStateStore()
assert isinstance(store, CascadeStateStore)
def test_increment_interaction(self):
store = InMemoryCascadeStateStore()
assert store.increment_interaction("s1") == 1
assert store.increment_interaction("s1") == 2
assert store.increment_interaction("s1") == 3
def test_increment_different_sessions(self):
store = InMemoryCascadeStateStore()
assert store.increment_interaction("s1") == 1
assert store.increment_interaction("s2") == 1
assert store.increment_interaction("s1") == 2
def test_get_interaction(self):
store = InMemoryCascadeStateStore()
store.increment_interaction("s1")
store.increment_interaction("s1")
assert store.get_interaction("s1") == 2
def test_get_interaction_nonexistent(self):
store = InMemoryCascadeStateStore()
assert store.get_interaction("unknown") == 0
def test_set_and_get_depth(self):
store = InMemoryCascadeStateStore()
store.set_depth("s1", 3)
assert store.get_depth("s1") == 3
def test_get_depth_nonexistent(self):
store = InMemoryCascadeStateStore()
assert store.get_depth("unknown") == 0
def test_reset(self):
store = InMemoryCascadeStateStore()
store.increment_interaction("s1")
store.increment_interaction("s1")
store.set_depth("s1", 2)
store.reset("s1")
assert store.get_interaction("s1") == 0
assert store.get_depth("s1") == 0
def test_reset_nonexistent_no_error(self):
store = InMemoryCascadeStateStore()
store.reset("nonexistent") # Should not raise
def test_ttl_expiry_interaction(self):
store = InMemoryCascadeStateStore(session_ttl=1) # 1 second TTL
store.increment_interaction("s1")
assert store.get_interaction("s1") == 1
time.sleep(1.1)
# After TTL, should return 0 (expired)
assert store.get_interaction("s1") == 0
def test_ttl_expiry_depth(self):
store = InMemoryCascadeStateStore(session_ttl=1)
store.set_depth("s1", 5)
assert store.get_depth("s1") == 5
time.sleep(1.1)
assert store.get_depth("s1") == 0
def test_ttl_cleanup_on_increment(self):
store = InMemoryCascadeStateStore(session_ttl=1)
store.increment_interaction("s1")
store.increment_interaction("s2")
time.sleep(1.1)
# Incrementing s3 should trigger cleanup of s1 and s2
store.increment_interaction("s3")
assert "s1" not in store._interaction_counts
assert "s2" not in store._interaction_counts
assert store._interaction_counts.get("s3") == 1
def test_touch_refreshes_ttl(self):
store = InMemoryCascadeStateStore(session_ttl=2)
store.increment_interaction("s1")
time.sleep(1.0)
# Touch refreshes the timestamp
store.increment_interaction("s1")
time.sleep(1.0)
# Should still be alive (1s < 2s since last touch)
assert store.get_interaction("s1") == 2
def test_default_session_ttl(self):
store = InMemoryCascadeStateStore()
assert store._session_ttl == 86400
def test_custom_session_ttl(self):
store = InMemoryCascadeStateStore(session_ttl=3600)
assert store._session_ttl == 3600
# ---------------------------------------------------------------------------
# RedisCascadeStateStore (mocked)
# ---------------------------------------------------------------------------
class TestRedisCascadeStateStoreMocked:
"""Tests for RedisCascadeStateStore with mocked Redis."""
def test_implements_protocol(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
assert isinstance(store, CascadeStateStore)
def test_degrade_to_fallback(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
assert not store._degraded
store._degrade_to_fallback()
assert store._degraded
assert isinstance(store._fallback, InMemoryCascadeStateStore)
def test_increment_degraded_uses_fallback(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
store._degrade_to_fallback()
result = store.increment_interaction("s1")
assert result == 1
assert store._fallback.get_interaction("s1") == 1
def test_get_interaction_degraded_uses_fallback(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
store._degrade_to_fallback()
store._fallback.increment_interaction("s1")
assert store.get_interaction("s1") == 1
def test_set_depth_degraded_uses_fallback(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
store._degrade_to_fallback()
store.set_depth("s1", 3)
assert store._fallback.get_depth("s1") == 3
def test_get_depth_degraded_uses_fallback(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
store._degrade_to_fallback()
store._fallback.set_depth("s1", 5)
assert store.get_depth("s1") == 5
def test_reset_degraded_uses_fallback(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
store._degrade_to_fallback()
store._fallback.increment_interaction("s1")
store.reset("s1")
assert store._fallback.get_interaction("s1") == 0
def test_increment_redis_failure_degrades(self):
store = RedisCascadeStateStore(redis_url="redis://nonexistent:6379")
# Will fail to connect and degrade
result = store.increment_interaction("s1")
assert store._degraded
assert result >= 0 # Should return something (fallback or 0)
def test_custom_session_ttl(self):
store = RedisCascadeStateStore(
redis_url="redis://localhost:6379", session_ttl=3600
)
assert store._session_ttl == 3600
def test_default_session_ttl(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
assert store._session_ttl == 86400
def test_increment_with_mock_redis(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
mock_redis = MagicMock()
mock_pipeline = MagicMock()
mock_pipeline.incr.return_value = mock_pipeline
mock_pipeline.expire.return_value = mock_pipeline
mock_pipeline.execute.return_value = [1, True]
mock_redis.pipeline.return_value = mock_pipeline
store._sync_redis = mock_redis
result = store.increment_interaction("s1")
assert result == 1
mock_pipeline.incr.assert_called_once()
mock_pipeline.expire.assert_called_once()
def test_get_interaction_with_mock_redis(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
mock_redis = MagicMock()
mock_redis.get.return_value = "3"
store._sync_redis = mock_redis
result = store.get_interaction("s1")
assert result == 3
def test_get_interaction_redis_returns_none(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
mock_redis = MagicMock()
mock_redis.get.return_value = None
store._sync_redis = mock_redis
result = store.get_interaction("s1")
assert result == 0
def test_set_depth_with_mock_redis(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
mock_redis = MagicMock()
mock_pipeline = MagicMock()
mock_pipeline.set.return_value = mock_pipeline
mock_pipeline.expire.return_value = mock_pipeline
mock_pipeline.execute.return_value = [True, True]
mock_redis.pipeline.return_value = mock_pipeline
store._sync_redis = mock_redis
store.set_depth("s1", 3)
mock_pipeline.set.assert_called_once()
mock_pipeline.expire.assert_called_once()
def test_reset_with_mock_redis(self):
store = RedisCascadeStateStore(redis_url="redis://localhost:6379")
mock_redis = MagicMock()
mock_pipeline = MagicMock()
mock_pipeline.delete.return_value = mock_pipeline
mock_pipeline.execute.return_value = [1, 1]
mock_redis.pipeline.return_value = mock_pipeline
store._sync_redis = mock_redis
store.reset("s1")
assert mock_pipeline.delete.call_count == 2
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
class TestCreateCascadeStateStore:
def test_memory_backend(self):
store = create_cascade_state_store(backend="memory")
assert isinstance(store, InMemoryCascadeStateStore)
def test_auto_backend_returns_store(self):
store = create_cascade_state_store(backend="auto")
assert isinstance(store, (InMemoryCascadeStateStore, RedisCascadeStateStore))
def test_redis_backend_returns_store(self):
store = create_cascade_state_store(backend="redis")
assert isinstance(store, (InMemoryCascadeStateStore, RedisCascadeStateStore))
def test_session_ttl_passed_to_redis(self):
store = create_cascade_state_store(
backend="redis", session_ttl=7200
)
if isinstance(store, RedisCascadeStateStore):
assert store._session_ttl == 7200
def test_session_ttl_passed_to_memory(self):
store = create_cascade_state_store(
backend="memory", session_ttl=7200
)
assert isinstance(store, InMemoryCascadeStateStore)
assert store._session_ttl == 7200