Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
76ad38b
test: stabilize stream assertions and browser queries
CSCITech Oct 14, 2026
a2276ce
test: resolve reconciliation controls by accessible name
CSCITech Oct 14, 2026
1310e4a
fix(web): stabilize shared components and editor controls
CSCITech Oct 14, 2026
e22f3f9
fix(web): isolate tag drafts and clarify callback boundaries
CSCITech Oct 14, 2026
2f917af
test(web): assert stable policy fallback before language switch
CSCITech Oct 14, 2026
ed9c73b
fix(web): stop waiting for unused tag model request
CSCITech Oct 14, 2026
ebb8113
fix(web): isolate async reads and complete effect lint cleanup
CSCITech Oct 15, 2026
db86de1
fix(web): preserve notification drafts across profile refreshes
CSCITech Oct 15, 2026
0631a20
fix(web): preserve channel affinity drafts for unchanged settings
CSCITech Oct 15, 2026
24e7c72
fix(web): retain tag model drafts and recover sidebar settings
CSCITech Oct 15, 2026
306b3df
fix(web): isolate wallet session reads and amount drafts
CSCITech Oct 15, 2026
7168f56
feat(ops): add safe sqlite archive rehearsal tooling
CSCITech Oct 15, 2026
ea8526b
test(sqlite): rehearse restored business snapshots
CSCITech Oct 15, 2026
ded5888
test(mysql): gate startup migration on isolated empty schema
CSCITech Oct 15, 2026
d451a05
test(mysql): allow explicitly named dedicated schema
CSCITech Oct 16, 2026
70ad875
test(mysql): audit initialized schema compatibility
CSCITech Oct 16, 2026
94a017d
test(mysql): pin TLS for external billing contract
CSCITech Oct 16, 2026
9cae057
feat(mysql): support pinned TLS certificates
CSCITech Oct 16, 2026
b7337d0
test(mysql): allow explicitly authorized billing schema
CSCITech Oct 16, 2026
e5cc71d
feat(taskplugin): validate plugin manifest contract
CSCITech Oct 16, 2026
d9d7360
feat(taskplugin): add atomic plugin registry
CSCITech Oct 16, 2026
e92dd46
test(taskplugin): add deterministic mock driver
CSCITech Oct 16, 2026
bc072cd
test(taskplugin): exercise sqlite settlement replay
CSCITech Oct 16, 2026
59acb85
Merge main and stabilize remaining frontend lifecycle acceptance tests
CSCITech Oct 20, 2026
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
13 changes: 9 additions & 4 deletions relay/channel/openai/first_result_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package openai

import (
"context"
"io"
"net/http"
"strings"
Expand All @@ -16,13 +17,14 @@ import (
"github.com/stretchr/testify/require"
)

// Use the real adapters and keep the provider open after metadata. The close
// is a bounded fallback: a broken watchdog must fail rather than hang the suite.
// Keep the provider open after metadata until cleanup. The request deadline
// bounds a broken watchdog without racing it against a fixture-generated EOF.
func stalledFirstResultStream(t *testing.T, frames []string) *http.Response {
t.Helper()
r, w := io.Pipe()
finished := make(chan struct{})
t.Cleanup(func() { _ = r.Close(); _ = w.Close(); <-finished })
release := make(chan struct{})
t.Cleanup(func() { close(release); _ = r.Close(); _ = w.Close(); <-finished })
go func() {
defer close(finished)
defer w.Close()
Expand All @@ -31,7 +33,7 @@ func stalledFirstResultStream(t *testing.T, frames []string) *http.Response {
return
}
}
time.Sleep(1500 * time.Millisecond)
<-release
}()
return &http.Response{StatusCode: http.StatusOK, Body: r}
}
Expand All @@ -57,6 +59,9 @@ func TestAdaptersMetadataDoesNotEndFirstResultWait(t *testing.T) {
*operation_setting.GetMonitorSetting() = operation_setting.MonitorSetting{StreamingFirstResultTimeoutSeconds: 1}
t.Cleanup(func() { *operation_setting.GetMonitorSetting() = oldSetting })
c, recorder, info := newChatStreamTest(t)
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
c.Request = c.Request.WithContext(ctx)
info.InitChannelMeta(c)
info.UpstreamModelName = "gpt-test"
common.EmptyCompletionRetryEnabled = test.buffer
Expand Down
87 changes: 60 additions & 27 deletions relay/helper/stream_scanner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,58 +302,91 @@ func TestStreamScannerHandler_ScannerDecoupledFromSlowHandler(t *testing.T) {
t.Parallel()

const numChunks = 50
const upstreamDelay = 10 * time.Millisecond
const handlerDelay = 20 * time.Millisecond

pr, pw := io.Pipe()
var upstreamBusy atomic.Int64
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
handlerEntered := make(chan struct{})
releaseHandler := make(chan struct{})
readAhead := make(chan struct{})
producerDone := make(chan struct{})
done := make(chan struct{})
var releaseOnce sync.Once
t.Cleanup(func() {
cancel()
releaseOnce.Do(func() { close(releaseHandler) })
_ = pr.Close()
_ = pw.Close()
for _, worker := range []<-chan struct{}{producerDone, done} {
select {
case <-worker:
case <-time.After(time.Second):
t.Error("stream test worker did not stop after cancellation")
}
}
})
go func() {
defer close(producerDone)
defer pw.Close()
feedStart := time.Now()
for i := 0; i < numChunks; i++ {
fmt.Fprintf(pw, "data: {\"id\":%d}\n", i)
time.Sleep(upstreamDelay)
if _, err := fmt.Fprintf(pw, "data: {\"id\":%d}\n", i); err != nil {
return
}
if i == 1 {
close(readAhead)
}
}
fmt.Fprint(pw, "data: [DONE]\n")
upstreamBusy.Store(int64(time.Since(feedStart)))
}()

recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx)

resp := &http.Response{Body: pr}
info := &relaycommon.RelayInfo{ChannelMeta: &relaycommon.ChannelMeta{}}

var count atomic.Int64
var handlerBusy atomic.Int64
start := time.Now()
done := make(chan struct{})
var received []string
go func() {
defer close(done)
StreamScannerHandler(c, resp, info, func(data string, sr *StreamResult) {
chunkStart := time.Now()
time.Sleep(handlerDelay)
handlerBusy.Add(int64(time.Since(chunkStart)))
count.Add(1)
if count.Add(1) == 1 {
close(handlerEntered)
select {
case <-releaseHandler:
case <-ctx.Done():
return
}
}
received = append(received, data)
})
close(done)
}()

// A coupled scanner cannot read the second pipe write while its first
// callback is blocked. Synchronize that contract without sleep-speed ratios.
for _, phase := range []struct {
name string
done <-chan struct{}
}{{"first handler", handlerEntered}, {"scanner read-ahead", readAhead}} {
select {
case <-phase.done:
case <-ctx.Done():
t.Fatalf("timed out waiting for %s", phase.name)
}
}
assert.Equal(t, int64(1), count.Load(), "only the blocked first callback can run")
releaseOnce.Do(func() { close(releaseHandler) })
select {
case <-done:
case <-time.After(15 * time.Second):
case <-ctx.Done():
t.Fatal("StreamScannerHandler did not complete in time")
}

elapsed := time.Since(start)
assert.Equal(t, int64(numChunks), count.Load())

// 用实测耗时估算"耦合"基线,避免系统繁忙时 sleep 超时偏移导致名义值失真
coupledTime := time.Duration(upstreamBusy.Load() + handlerBusy.Load())
t.Logf("elapsed=%v, coupled_estimate=%v", elapsed, coupledTime)

assert.Less(t, elapsed, coupledTime*85/100,
"decoupled elapsed time (%v) should be significantly less than coupled estimate (%v)", elapsed, coupledTime)
assert.Equal(t, numChunks, info.ReceivedResponseCount)
require.Len(t, received, numChunks)
for i, data := range received {
assert.Equal(t, fmt.Sprintf("{\"id\":%d}", i), data)
}
assert.Equal(t, relaycommon.StreamEndReasonDone, info.StreamStatus.EndReason)
}

func TestStreamScannerHandler_SlowUpstreamFastHandler(t *testing.T) {
Expand Down
4 changes: 3 additions & 1 deletion relay/responses_first_result_settlement_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,9 @@ func TestResponsesFirstResultTimeoutSettlesDeliveredWhitespace(t *testing.T) {
case <-r.Context().Done():
upstreamCanceled <- true
case <-time.After(3 * time.Second):
upstreamCanceled <- false
// A delayed select may see both channels ready. Confirm
// the request state before reporting a missing cancellation.
upstreamCanceled <- r.Context().Err() != nil
}
}))
t.Cleanup(upstream.Close)
Expand Down
9 changes: 9 additions & 0 deletions web/default/scripts/run-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -274,13 +274,22 @@ function runTestFiles(files, label, bunArguments) {
if (files.length === 0) return 'skipped'

console.log(`\n==> ${label} (${files.length} file${files.length === 1 ? '' : 's'})`)
const startedAt = performance.now()
const result = Bun.spawnSync({
cmd: buildTestCommand(files, bunArguments),
cwd: projectRoot,
env: process.env,
stdout: 'pipe',
stderr: 'pipe',
})
console.log(
`MAX_API_TEST_STAGE ${JSON.stringify({
label,
fileCount: files.length,
exitCode: result.exitCode,
wallMilliseconds: Math.round(performance.now() - startedAt),
})}`
)
writeChildOutput(result)
if (result.exitCode === 0) return 'passed'

Expand Down
9 changes: 2 additions & 7 deletions web/default/src/components/ai-elements/web-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ import {
createContext,
type ReactNode,
useContext,
useEffect,
useState,
} from 'react'
import { ChevronDownIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import dayjs from '@/lib/dayjs'
import { cn } from '@/lib/utils'
import { useResettableState } from '@/hooks/use-resettable-state'
import { Button } from '@/components/ui/button'
import {
Collapsible,
Expand Down Expand Up @@ -162,12 +162,7 @@ export const WebPreviewUrl = ({
}: WebPreviewUrlProps) => {
const { t } = useTranslation()
const { url, setUrl } = useWebPreview()
const [inputValue, setInputValue] = useState(url)

// Sync input value with context URL when it changes externally
useEffect(() => {
setInputValue(url)
}, [url])
const [inputValue, setInputValue] = useResettableState(url, url)

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(event.target.value)
Expand Down
1 change: 0 additions & 1 deletion web/default/src/components/long-text.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ export function LongText({

useEffect(() => {
if (checkOverflow(ref.current)) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setIsOverflown(true)
return
}
Expand Down
34 changes: 19 additions & 15 deletions web/default/src/components/risk-acknowledgement-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.

For commercial licensing, please contact https://github.com/MAX-API-Next/MAX-API/issues
*/
import { useEffect, useMemo, useState } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import { useResettableState } from '@/hooks/use-resettable-state'
import {
AlertDialog,
AlertDialogCancel,
Expand Down Expand Up @@ -83,22 +84,21 @@ export function RiskAcknowledgementDialog({
className,
}: RiskAcknowledgementDialogProps) {
const { t } = useTranslation()
const [checkedItems, setCheckedItems] = useState<boolean[]>([])
const [typedText, setTypedText] = useState('')
const [typedTextParts, setTypedTextParts] = useState<string[]>([])

const normalizedRequiredTextParts = useMemo<
NormalizedRequiredTextPart[]
>(() => {
let inputIndex = 0
return requiredTextParts.map((part) => {
const parts: NormalizedRequiredTextPart[] = []
for (const part of requiredTextParts) {
if (part.type === 'input') {
const normalizedPart = { ...part, inputIndex }
parts.push({ ...part, inputIndex })
inputIndex += 1
return normalizedPart
} else {
parts.push(part)
}
return part
})
}
return parts
}, [requiredTextParts])

const requiredTextInputCount = useMemo(
Expand All @@ -112,12 +112,16 @@ export function RiskAcknowledgementDialog({
? normalizedRequiredTextParts.map((part) => part.text).join('')
: requiredText

useEffect(() => {
if (!open) return
setCheckedItems(Array(checklist.length).fill(false))
setTypedText('')
setTypedTextParts(Array(requiredTextInputCount).fill(''))
}, [open, checklist.length, requiredTextInputCount])
const draftKey = `${open}:${checklist.length}:${requiredTextInputCount}`
const [checkedItems, setCheckedItems] = useResettableState<boolean[]>(
() => Array(checklist.length).fill(false),
draftKey
)
const [typedText, setTypedText] = useResettableState('', draftKey)
const [typedTextParts, setTypedTextParts] = useResettableState<string[]>(
() => Array(requiredTextInputCount).fill(''),
draftKey
)

const allChecked = useMemo(() => {
if (checklist.length === 0) return true
Expand Down
17 changes: 9 additions & 8 deletions web/default/src/features/auth/hooks/use-oauth-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,21 @@ type LogoutRequestConfig = AxiosRequestConfig & {
export function useOAuthLogin(status: SystemStatus | null) {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(false)
const [githubButtonText, setGithubButtonText] = useState('')
const [githubButtonMessage, setGithubButtonText] = useState(
'Continue with GitHub'
)
const githubButtonText = t(githubButtonMessage)
const [githubButtonDisabled, setGithubButtonDisabled] = useState(false)
const githubTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const { auth } = useAuthStore()

useEffect(() => {
setGithubButtonText(t('Continue with GitHub'))

return () => {
if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
}
}
}, [t])
}, [])

const resetSession = async () => {
try {
Expand All @@ -77,7 +78,7 @@ export function useOAuthLogin(status: SystemStatus | null) {

setIsLoading(true)
setGithubButtonDisabled(true)
setGithubButtonText(t('Redirecting to GitHub...'))
setGithubButtonText('Redirecting to GitHub...')

if (githubTimeoutRef.current) {
clearTimeout(githubTimeoutRef.current)
Expand All @@ -86,7 +87,7 @@ export function useOAuthLogin(status: SystemStatus | null) {
githubTimeoutRef.current = setTimeout(() => {
setIsLoading(false)
setGithubButtonText(
t('Request timed out, please refresh and restart GitHub login')
'Request timed out, please refresh and restart GitHub login'
)
setGithubButtonDisabled(true)
}, 20000)
Expand All @@ -100,7 +101,7 @@ export function useOAuthLogin(status: SystemStatus | null) {
clearTimeout(githubTimeoutRef.current)
}
setIsLoading(false)
setGithubButtonText(t('Continue with GitHub'))
setGithubButtonText('Continue with GitHub')
setGithubButtonDisabled(false)
return
}
Expand All @@ -113,7 +114,7 @@ export function useOAuthLogin(status: SystemStatus | null) {
clearTimeout(githubTimeoutRef.current)
}
setIsLoading(false)
setGithubButtonText(t('Continue with GitHub'))
setGithubButtonText('Continue with GitHub')
setGithubButtonDisabled(false)
}
}
Expand Down
Loading
Loading