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
4 changes: 2 additions & 2 deletions contracts/gateway/v4/compatibility-manifest.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@
"lifecycle": "stable",
"name": "chat.history",
"schema": "conversation/chat-history.schema.json",
"schemaSha256": "1e03b12ae015c0875c29d943c0f8d1969bd225b69978c2a8a36e0135785cab85"
"schemaSha256": "8e280ed656a595a3d9cf4fafd1d6c9ec064c5f648a94b529c43cbb13d1a688a8"
},
{
"lifecycle": "stable",
Expand Down Expand Up @@ -1466,7 +1466,7 @@
"generatorSha256": "f781c6d8e31b336c2d2e342784935b3d327c9947cb35e56de8e44a0a83bffae6",
"methodCount": 215,
"schemaCount": 225,
"schemaTreeSha256": "e667c15f23b95372ad843172a3f3581f9be8b6b1b4fc3693c07a5f317bc43eaa"
"schemaTreeSha256": "ecef928334e2ed47be4fee8de109d2de74ab2cbc22d26e8691d47e6754cdbc7e"
},
"wireVersion": 4
}
12 changes: 10 additions & 2 deletions contracts/gateway/v4/conversation/chat-history.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://opensquilla.dev/contracts/gateway/v4/conversation/chat-history.schema.json",
"title": "OpenSquilla chat.history Contract",
"description": "Language-neutral Contract for the v4 chat.history query. The wire keeps legacy default-session, cursor, limit and boolean coercion behavior while publishing one canonical history projection.",
"description": "Language-neutral Contract for the v4 chat.history query. The wire keeps legacy default-session, null/blank cursor, limit and boolean coercion behavior while publishing one canonical history projection. Non-empty cursors are strict ASCII-decimal created_at|id anchors; malformed cursors and anchors that no longer belong to the session fail explicitly.",
"type": "object",
"additionalProperties": false,
"properties": {
Expand Down Expand Up @@ -72,6 +72,14 @@
"retryAfterMs": true,
"details": true
},
{
"code": "HISTORY_CURSOR_INVALID",
"semantics": "non-empty-supplied-cursor-malformed-or-out-of-range"
},
{
"code": "HISTORY_CURSOR_INVALIDATED",
"semantics": "supplied-cursor-anchor-missing-from-session"
},
{
"code": "INTERNAL_ERROR"
}
Expand All @@ -92,7 +100,7 @@
},
"ChatHistoryParams": {
"title": "chat.history params",
"description": "All fields are optional for v4 compatibility. A null or absent sessionKey selects the canonical default WebChat session. Unknown fields are ignored.",
"description": "All fields are optional for v4 compatibility. A null or absent sessionKey selects the canonical default WebChat session. Null and blank before/after values remain unpositioned reads. After trimming outer whitespace, a non-empty cursor must contain exactly two non-negative signed-64-bit ASCII decimal components in created_at|id form and must anchor this session. Malformed cursors return HISTORY_CURSOR_INVALID, missing anchors return HISTORY_CURSOR_INVALIDATED, and a valid before cursor takes precedence over after. Unknown fields are ignored.",
"type": "object",
"additionalProperties": true,
"properties": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
SessionReadContractError,
SessionReadFailure,
SessionReadHistoryCursorError,
SessionReadLeaseClosedError,
SessionReadSessionMissingError,
} from '@/modules/sessionReadLifecycle'
Expand All @@ -10,6 +11,7 @@ export function mapSessionReadError(error: unknown): Error {
if (
error instanceof SessionReadFailure
|| error instanceof SessionReadContractError
|| error instanceof SessionReadHistoryCursorError
|| error instanceof SessionReadLeaseClosedError
|| error instanceof SessionReadSessionMissingError
) return error
Expand All @@ -18,6 +20,13 @@ export function mapSessionReadError(error: unknown): Error {
if (code === 'NOT_FOUND' || code === 'SESSION_NOT_FOUND') {
return new SessionReadSessionMissingError(failure.message, error)
}
if (code === 'HISTORY_CURSOR_INVALID' || code === 'HISTORY_CURSOR_INVALIDATED') {
return new SessionReadHistoryCursorError(
code === 'HISTORY_CURSOR_INVALID' ? 'invalid' : 'stale',
failure.message,
error,
)
}
const kind = code === 'SNAPSHOT_TOO_LARGE'
? 'too-large'
: code === 'RPC_ABORTED' || (error instanceof Error && error.name === 'AbortError')
Expand Down
16 changes: 16 additions & 0 deletions opensquilla-webui/src/adapters/gateway/sessionReadPortV4.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { SESSIONS_MESSAGES_UNSUBSCRIBE_METHOD } from '@/contracts/generated/v4/s
import {
SessionReadContractError,
SessionReadFailure,
SessionReadHistoryCursorError,
SessionReadSessionMissingError,
type SessionReadMetadata,
} from '@/modules/sessionReadLifecycle'
Expand Down Expand Up @@ -508,6 +509,21 @@ describe('v4 SessionReadPort Adapter', () => {
} satisfies Partial<SessionReadFailure>)
})

it.each([
['HISTORY_CURSOR_INVALID', 'invalid'],
['history_cursor_invalidated', 'stale'],
] as const)('maps %s to reload-latest cursor recovery', (code, reason) => {
const cause = Object.assign(new Error('cursor rejected'), { code })

expect(mapSessionReadError(cause)).toMatchObject({
name: 'SessionReadHistoryCursorError',
code: 'history-cursor-rejected',
reason,
recovery: 'reload-latest',
cause,
} satisfies Partial<SessionReadHistoryCursorError>)
})

it('queues critical frames in order while live, metadata and history settle independently', async () => {
const harness = makeHarness()
const subscribe = deferred<SessionsMessagesSubscribeResult>()
Expand Down
141 changes: 141 additions & 0 deletions opensquilla-webui/src/composables/chat/useChatHistory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { projectAssistantActivityTimeline } from '@/utils/chat/assistantActivity
import type { ChatMessage, ChatTurnOutcome } from '@/types/chat'
import { RpcTimeoutError } from '@/lib/rpc'
import {
SessionReadHistoryCursorError,
SessionReadSessionMissingError,
type SessionReadCompactionSummary,
type SessionReadHistoryPage,
Expand Down Expand Up @@ -2363,6 +2364,146 @@ describe('useChatHistory canonical pagination', () => {
expect(readHistory).toHaveBeenCalledTimes(3)
})

it('refreshes background history after an unrelated earlier-page failure', async () => {
vi.useFakeTimers()
const { api, readHistory, historyFixture, messages } = makeHistory(false)
try {
historyFixture
.mockResolvedValueOnce({
messages: [historyMessage('m4')],
hasMore: true,
oldestCursor: 'cursor-4',
newestCursor: 'cursor-4',
canonicalAvailable: true,
})
.mockRejectedValueOnce(new Error('offline'))
.mockResolvedValueOnce({
messages: [historyMessage('m4'), historyMessage('m9')],
hasMore: true,
oldestCursor: 'cursor-4',
newestCursor: 'cursor-9',
canonicalAvailable: true,
})

await api.loadHistory()
await api.loadEarlierHistory()
expect(api.historyState.value.loadEarlierError).toBe(true)

api.scheduleHistorySync(true)
await vi.advanceTimersByTimeAsync(60)

expect(readHistory).toHaveBeenCalledTimes(3)
expect(readHistory).toHaveBeenLastCalledWith('latest', null, expect.any(Object))
expect(messages.value.map(message => message.messageId)).toEqual(['m4', 'm9'])
expect(api.historyState.value.loadEarlierError).toBe(false)
} finally {
api.cleanup()
vi.useRealTimers()
}
})

it.each(['invalid', 'stale'] as const)(
'keeps a %s cursor failure explicit until latest recovery replaces the window',
async reason => {
const { api, readHistory, historyFixture, messages } = makeHistory(false)
const cursorError = new SessionReadHistoryCursorError(reason, 'cursor rejected')
historyFixture
.mockResolvedValueOnce({
messages: [historyMessage('m4')],
hasMore: true,
oldestCursor: 'cursor-4',
newestCursor: 'cursor-4',
canonicalAvailable: true,
})
.mockRejectedValueOnce(cursorError)
.mockResolvedValueOnce({
messages: [historyMessage('m9')],
hasMore: false,
oldestCursor: 'cursor-9',
newestCursor: 'cursor-9',
canonicalAvailable: true,
})

await api.loadHistory()
await api.loadEarlierHistory()
await expect(api.loadHistory()).resolves.toMatchObject({ ok: false, error: cursorError })
await expect(api.reconcileHistory()).resolves.toMatchObject({ ok: false, error: cursorError })
expect(readHistory).toHaveBeenCalledTimes(2)
expect(messages.value.map(message => message.messageId)).toEqual(['m4'])
await api.retryHistory()

expect(readHistory).toHaveBeenNthCalledWith(
2,
'before',
'cursor-4',
expect.any(Object),
)
expect(readHistory).toHaveBeenNthCalledWith(3, 'latest', null, expect.any(Object))
expect(messages.value.map(message => message.messageId)).toEqual(['m9'])
expect(api.historyState.value).toMatchObject({
oldestCursor: 'cursor-9',
newestCursor: 'cursor-9',
loadEarlierError: false,
recoveryError: false,
})
api.cleanup()
},
)

it('shares a failed latest recovery and retries it without returning to the rejected cursor', async () => {
const { api, readHistory, historyFixture, messages } = makeHistory(false)
const recoveryError = new Error('connection unavailable')
let rejectRecovery!: (error: Error) => void
const recovery = new Promise<SessionReadHistoryPageFixture>((_resolve, reject) => {
rejectRecovery = reject
})
historyFixture
.mockResolvedValueOnce({
messages: [historyMessage('m4')],
hasMore: true,
oldestCursor: 'cursor-4',
newestCursor: 'cursor-4',
canonicalAvailable: true,
})
.mockRejectedValueOnce(new SessionReadHistoryCursorError('stale', 'cursor rejected'))
.mockReturnValueOnce(recovery)
.mockResolvedValueOnce({
messages: [historyMessage('m9')],
hasMore: false,
oldestCursor: 'cursor-9',
newestCursor: 'cursor-9',
canonicalAvailable: true,
})

await api.loadHistory()
await api.loadEarlierHistory()
const retry = api.retryHistory()
const joined = api.loadHistory()
const reconciliation = api.reconcileHistory()
expect(joined).toBe(retry)
rejectRecovery(recoveryError)

for (const pending of [retry, joined, reconciliation]) {
await expect(pending).resolves.toMatchObject({ ok: false, error: recoveryError })
}
expect(readHistory).toHaveBeenCalledTimes(3)
expect(messages.value.map(message => message.messageId)).toEqual(['m4'])
expect(api.historyState.value.recoveryError).toBe(true)

await expect(api.retryHistory()).resolves.toMatchObject({ ok: true })
expect(readHistory.mock.calls.map(call => call[0])).toEqual([
'latest', 'before', 'latest', 'latest',
])
expect(messages.value.map(message => message.messageId)).toEqual(['m9'])
expect(api.historyState.value).toMatchObject({
oldestCursor: 'cursor-9',
newestCursor: 'cursor-9',
loadEarlierError: false,
recoveryError: false,
})
api.cleanup()
})

it('surfaces and retries an initial history request failure', async () => {
const { api, historyFixture, messages } = makeHistory(false)
historyFixture
Expand Down
Loading
Loading