From 6072d2aa96e488d8ce3af000b7a6a81b1dcf0da7 Mon Sep 17 00:00:00 2001 From: lihongguang-0014 Date: Mon, 7 Sep 2026 17:16:28 +0800 Subject: [PATCH 1/6] fix(history): reject invalid and stale cursors --- .../v4/compatibility-manifest.generated.json | 4 +- .../v4/conversation/chat-history.schema.json | 12 +- .../gateway/sessionReadErrorMapping.ts | 9 ++ .../gateway/sessionReadPortV4.test.ts | 16 +++ .../composables/chat/useChatHistory.test.ts | 42 ++++++ .../src/composables/chat/useChatHistory.ts | 66 ++++++++-- .../sessions/useSessionInspect.test.ts | 26 +++- .../composables/sessions/useSessionInspect.ts | 20 ++- .../src/contracts/generated/v4/chatHistory.ts | 8 +- .../generated/v4/chatHistoryValidators.d.mts | 2 +- .../generated/v4/chatHistoryValidators.mjs | 4 +- .../src/modules/sessionReadLifecycle.ts | 17 +++ .../application/session_history.py | 118 ++++++++++------- .../contracts/generated/v4/chat_history.py | 6 +- .../generated/v4/chat_history_metadata.py | 4 +- .../generated/v4/gateway_contract_registry.py | 4 +- .../gateway/adapters/session_history.py | 101 ++++++++------ .../adapters/session_history_projection.py | 56 ++++++-- src/opensquilla/history_cursor.py | 23 ++++ src/opensquilla/session/storage.py | 113 +++++++++------- .../test_application/test_session_history.py | 31 ++++- .../test_chat_history_characterization.py | 2 +- tests/test_gateway/test_rpc_chat_history.py | 117 +++++++++++++++++ .../test_session_history_adapter.py | 74 ++++++++++- tests/test_session/test_manager.py | 124 ++++++++++++++++++ 25 files changed, 810 insertions(+), 189 deletions(-) create mode 100644 src/opensquilla/history_cursor.py diff --git a/contracts/gateway/v4/compatibility-manifest.generated.json b/contracts/gateway/v4/compatibility-manifest.generated.json index d1a933dcae..11caff6b2e 100644 --- a/contracts/gateway/v4/compatibility-manifest.generated.json +++ b/contracts/gateway/v4/compatibility-manifest.generated.json @@ -347,7 +347,7 @@ "lifecycle": "stable", "name": "chat.history", "schema": "conversation/chat-history.schema.json", - "schemaSha256": "1e03b12ae015c0875c29d943c0f8d1969bd225b69978c2a8a36e0135785cab85" + "schemaSha256": "8e280ed656a595a3d9cf4fafd1d6c9ec064c5f648a94b529c43cbb13d1a688a8" }, { "lifecycle": "stable", @@ -1460,7 +1460,7 @@ "generatorSha256": "0e0b513d7844d926e5f2c6065e64e4b523a1f8df2023317327ee6e05bfa29038", "methodCount": 214, "schemaCount": 224, - "schemaTreeSha256": "7479e55aa84897a526ea6a8c8b5dca3f995ffb7fdde4dc57bc84123e21cdf8fc" + "schemaTreeSha256": "14d1fcbfa3c80144c3fe0547f604b12ac54aa5e29492c40230fc9ce34919b934" }, "wireVersion": 4 } diff --git a/contracts/gateway/v4/conversation/chat-history.schema.json b/contracts/gateway/v4/conversation/chat-history.schema.json index 1017c5614c..1c73f35f82 100644 --- a/contracts/gateway/v4/conversation/chat-history.schema.json +++ b/contracts/gateway/v4/conversation/chat-history.schema.json @@ -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": { @@ -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" } @@ -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": { diff --git a/opensquilla-webui/src/adapters/gateway/sessionReadErrorMapping.ts b/opensquilla-webui/src/adapters/gateway/sessionReadErrorMapping.ts index 159737b4ce..7f65cbd6c0 100644 --- a/opensquilla-webui/src/adapters/gateway/sessionReadErrorMapping.ts +++ b/opensquilla-webui/src/adapters/gateway/sessionReadErrorMapping.ts @@ -1,6 +1,7 @@ import { SessionReadContractError, SessionReadFailure, + SessionReadHistoryCursorError, SessionReadLeaseClosedError, SessionReadSessionMissingError, } from '@/modules/sessionReadLifecycle' @@ -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 @@ -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') diff --git a/opensquilla-webui/src/adapters/gateway/sessionReadPortV4.test.ts b/opensquilla-webui/src/adapters/gateway/sessionReadPortV4.test.ts index 8108e729fc..decd7a274d 100644 --- a/opensquilla-webui/src/adapters/gateway/sessionReadPortV4.test.ts +++ b/opensquilla-webui/src/adapters/gateway/sessionReadPortV4.test.ts @@ -22,6 +22,7 @@ import { SESSIONS_MESSAGES_UNSUBSCRIBE_METHOD } from '@/contracts/generated/v4/s import { SessionReadContractError, SessionReadFailure, + SessionReadHistoryCursorError, SessionReadSessionMissingError, type SessionReadMetadata, } from '@/modules/sessionReadLifecycle' @@ -508,6 +509,21 @@ describe('v4 SessionReadPort Adapter', () => { } satisfies Partial) }) + 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) + }) + it('queues critical frames in order while live, metadata and history settle independently', async () => { const harness = makeHarness() const subscribe = deferred() diff --git a/opensquilla-webui/src/composables/chat/useChatHistory.test.ts b/opensquilla-webui/src/composables/chat/useChatHistory.test.ts index 3c272d54b0..36e5f70941 100644 --- a/opensquilla-webui/src/composables/chat/useChatHistory.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatHistory.test.ts @@ -6,6 +6,7 @@ import { useChatHistory } from './useChatHistory' import type { ChatMessage, ChatTurnOutcome } from '@/types/chat' import { RpcTimeoutError } from '@/lib/rpc' import { + SessionReadHistoryCursorError, SessionReadSessionMissingError, type SessionReadCompactionSummary, type SessionReadHistoryPage, @@ -2186,6 +2187,47 @@ describe('useChatHistory canonical pagination', () => { expect(readHistory).toHaveBeenCalledTimes(3) }) + it('replaces stale canonical rows by retrying a rejected cursor from latest', async () => { + const { api, readHistory, historyFixture, messages } = makeHistory(false) + historyFixture + .mockResolvedValueOnce({ + messages: [historyMessage('m4')], + hasMore: true, + oldestCursor: 'cursor-4', + newestCursor: 'cursor-4', + canonicalAvailable: true, + }) + .mockRejectedValueOnce( + new SessionReadHistoryCursorError('stale', 'cursor rejected'), + ) + .mockResolvedValueOnce({ + messages: [historyMessage('m9')], + hasMore: false, + oldestCursor: 'cursor-9', + newestCursor: 'cursor-9', + canonicalAvailable: true, + }) + + await api.loadHistory() + await api.loadEarlierHistory() + 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, + }) + }) + it('surfaces and retries an initial history request failure', async () => { const { api, historyFixture, messages } = makeHistory(false) historyFixture diff --git a/opensquilla-webui/src/composables/chat/useChatHistory.ts b/opensquilla-webui/src/composables/chat/useChatHistory.ts index 3730781c10..c0d7118227 100644 --- a/opensquilla-webui/src/composables/chat/useChatHistory.ts +++ b/opensquilla-webui/src/composables/chat/useChatHistory.ts @@ -33,6 +33,7 @@ import { type SessionPhaseResult, } from '@/composables/chat/sessionBootstrapContract' import { + SessionReadHistoryCursorError, SessionReadSessionMissingError, type SessionReadCompactionSummary, type SessionReadHistoryPage, @@ -704,6 +705,7 @@ interface HistoryLoadParams { bridgeRetry?: boolean retry?: boolean nonReconnecting?: boolean + replaceCanonicalWindow?: boolean } type FailedHistoryRequest = @@ -717,6 +719,10 @@ type FailedHistoryRequest = kind: 'bridge' key: string } + | { + kind: 'latest' + key: string + } const MAX_FORWARD_BRIDGE_PAGES = 2 @@ -786,7 +792,7 @@ export function useChatHistory(options: UseChatHistoryOptions) { historySyncTimer = null const timerNonReconnecting = historySyncTimerNonReconnecting historySyncTimerNonReconnecting = false - if (historyState.value.loading) { + if (historyState.value.loading || failedHistoryRequest) { historySyncPending = true historySyncPendingNonReconnecting ||= timerNonReconnecting return @@ -1124,8 +1130,17 @@ export function useChatHistory(options: UseChatHistoryOptions) { data, ) const previousMessages = crossedSession ? [] : options.messages.value - const previousMaintenance = previousMessages.filter(isHistoryMaintenance) - const previousTranscript = previousMessages.filter(message => !isHistoryMaintenance(message)) + const previousMaintenance = previousMessages.filter(message => ( + isHistoryMaintenance(message) + && (!params.replaceCanonicalWindow || message.restoredFromHistory !== true) + )) + const previousTranscript = previousMessages.filter(message => ( + !isHistoryMaintenance(message) + && ( + !params.replaceCanonicalWindow + || (message.restoredFromHistory !== true && !message.terminalNotice) + ) + )) const maintenanceMessages = compactionSummaryMessages(data) let historyData = data let bridgeContinuationNeeded = false @@ -1328,7 +1343,7 @@ export function useChatHistory(options: UseChatHistoryOptions) { } else { const refreshedWindow = reconcileHistoryWindow(previousTranscript, mapped) let nextMessages: ChatMessage[] - if (preserveLiveTail) { + if (params.replaceCanonicalWindow || preserveLiveTail) { nextMessages = reconcileRunningHistoryMessages(previousTranscript, refreshedWindow) } else { nextMessages = refreshedWindow @@ -1431,7 +1446,8 @@ export function useChatHistory(options: UseChatHistoryOptions) { } catch (error: unknown) { // History endpoint may not exist yet. if (isCurrentRequest()) { - if (nonReconnecting) { + const cursorRequiresLatestReload = error instanceof SessionReadHistoryCursorError + if (nonReconnecting && !cursorRequiresLatestReload) { restoreSilentBackgroundState() return { ok: false, @@ -1440,14 +1456,16 @@ export function useChatHistory(options: UseChatHistoryOptions) { } } const initialLoadFailed = isInitialLoad && !bridgeAttempted - failedHistoryRequest = bridgeAttempted - ? { kind: 'bridge', key } - : { - kind: 'page', - key, - before: params.before ?? null, - prepend: Boolean(params.prepend), - } + failedHistoryRequest = cursorRequiresLatestReload || params.replaceCanonicalWindow + ? { kind: 'latest', key } + : bridgeAttempted + ? { kind: 'bridge', key } + : { + kind: 'page', + key, + before: params.before ?? null, + prepend: Boolean(params.prepend), + } historyState.value = { ...historyState.value, loading: false, @@ -1477,6 +1495,15 @@ export function useChatHistory(options: UseChatHistoryOptions) { ): Promise | undefined { const key = options.sessionKey.value if (!key) return + if ( + failedHistoryRequest?.key === key + && failedHistoryRequest.kind === 'latest' + && !params.replaceCanonicalWindow + ) { + historySyncPending = true + historySyncPendingNonReconnecting ||= Boolean(params.nonReconnecting) + return + } if (activeHistory) { if ( activeHistory.key === key @@ -1547,6 +1574,19 @@ export function useChatHistory(options: UseChatHistoryOptions) { function retryHistory(bootstrap?: SessionBootstrapPhaseContext) { const failed = failedHistoryRequest if (failed?.key === options.sessionKey.value) { + if (failed.kind === 'latest') { + hasLoadedEarlier = false + loadEarlierPending = false + loadedEarlierCursors.clear() + failedHistoryRequest = null + historyState.value = { + ...historyState.value, + hasMore: false, + oldestCursor: null, + newestCursor: null, + } + return loadHistory({ replaceCanonicalWindow: true, retry: true }, bootstrap) + } if (failed.kind === 'bridge') { return loadHistory({ bridgeRetry: true, retry: true }, bootstrap) } diff --git a/opensquilla-webui/src/composables/sessions/useSessionInspect.test.ts b/opensquilla-webui/src/composables/sessions/useSessionInspect.test.ts index 6c3e10495f..0dc8e0fa7a 100644 --- a/opensquilla-webui/src/composables/sessions/useSessionInspect.test.ts +++ b/opensquilla-webui/src/composables/sessions/useSessionInspect.test.ts @@ -2,9 +2,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { SessionInspection } from '@/modules/sessionInspection' import type { TurnCommands } from '@/modules/turnCommands' -import type { - SessionReadHistoryPage, - SessionReadMessage, +import { + SessionReadHistoryCursorError, + type SessionReadHistoryPage, + type SessionReadMessage, } from '@/modules/sessionReadLifecycle' import { abortInspectedSession, useSessionInspect } from './useSessionInspect' @@ -114,6 +115,25 @@ describe('useSessionInspect canonical pagination', () => { expect(before).toHaveBeenCalledTimes(2) }) + it('retries a rejected earlier cursor from latest', async () => { + latest + .mockResolvedValueOnce(page('m4', 'cursor-4', true)) + .mockResolvedValueOnce(page('m9', 'cursor-9', false)) + before.mockRejectedValueOnce( + new SessionReadHistoryCursorError('stale', 'cursor rejected'), + ) + const inspect = useSessionInspect(inspection) + + await inspect.load('agent:main:webchat:test') + await inspect.loadEarlier() + await inspect.retryHistory() + + expect(before).toHaveBeenCalledTimes(1) + expect(latest).toHaveBeenCalledTimes(2) + expect(inspect.messages.value.map(message => message.messageId)).toEqual(['m9']) + expect(inspect.loadEarlierError.value).toBe(false) + }) + it('does not advance an unavailable earlier page and retries the same cursor', async () => { latest.mockResolvedValueOnce(page('m4', 'cursor-4', true)) before diff --git a/opensquilla-webui/src/composables/sessions/useSessionInspect.ts b/opensquilla-webui/src/composables/sessions/useSessionInspect.ts index 0579bdea99..6f5aa5f441 100644 --- a/opensquilla-webui/src/composables/sessions/useSessionInspect.ts +++ b/opensquilla-webui/src/composables/sessions/useSessionInspect.ts @@ -1,6 +1,9 @@ import { ref } from 'vue' import type { SessionInspection } from '@/modules/sessionInspection' -import type { SessionReadMessage } from '@/modules/sessionReadLifecycle' +import { + SessionReadHistoryCursorError, + type SessionReadMessage, +} from '@/modules/sessionReadLifecycle' import type { TurnCommands } from '@/modules/turnCommands' // The inspect drawer composes a bounded preview with canonical transcript pages. @@ -44,6 +47,7 @@ export function useSessionInspect(sessionInspection: SessionInspection) { let failedTranscriptRequest: { key: string before: string | number | null + reloadLatest?: boolean } | null = null const loadedEarlierCursors = new Set() @@ -142,8 +146,13 @@ export function useSessionInspect(sessionInspection: SessionInspection) { if (seq === requestSeq && applied === true) { loadedEarlierCursors.add(String(cursor)) } - } catch { - if (seq === requestSeq) loadEarlierError.value = true + } catch (error) { + if (seq === requestSeq) { + if (error instanceof SessionReadHistoryCursorError) { + failedTranscriptRequest = { key: currentKey, before: null, reloadLatest: true } + } + loadEarlierError.value = true + } } finally { if (seq === requestSeq) loadingEarlier.value = false } @@ -158,8 +167,9 @@ export function useSessionInspect(sessionInspection: SessionInspection) { function retryHistory(beforeApply?: () => void) { const failed = failedTranscriptRequest - if (failed?.key === currentKey && failed.before != null) { - return requestEarlier(failed.before, beforeApply) + if (failed?.key === currentKey) { + if (failed.reloadLatest) return load(currentKey) + if (failed.before != null) return requestEarlier(failed.before, beforeApply) } if (canonicalAvailable.value === false) { return currentKey ? load(currentKey) : undefined diff --git a/opensquilla-webui/src/contracts/generated/v4/chatHistory.ts b/opensquilla-webui/src/contracts/generated/v4/chatHistory.ts index d27fe7e71e..113b06688a 100644 --- a/opensquilla-webui/src/contracts/generated/v4/chatHistory.ts +++ b/opensquilla-webui/src/contracts/generated/v4/chatHistory.ts @@ -1,5 +1,5 @@ // @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -// source-sha256: 8f6b9efa8c2ba853cee0e0de7c70a83288c811736e94a5d07b3aca7b01db2078 +// source-sha256: de253cb00a07e9955c452bd2b27bbcc761543148cc11ee1614b47e7b84e74fed /** * v4 ReqFrame.params is Any. Truthy non-object values reach the legacy handler and are reported through an error response rather than rejected by the transport. @@ -25,7 +25,7 @@ export type NullableString = string | null; export type NullableCursor = string | null; /** - * 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. + * 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. */ export interface OpenSquillaChatHistoryContract { request?: ChatHistoryRequestFrame; @@ -42,7 +42,7 @@ export interface ChatHistoryRequestFrame { params?: ChatHistoryParams | ChatHistoryLegacyNonObjectParams | null; } /** - * All fields are optional for v4 compatibility. A null or absent sessionKey selects the canonical default WebChat session. Unknown fields are ignored. + * 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. * * This interface was referenced by `OpenSquillaChatHistoryContract`'s JSON-Schema * via the `definition` "ChatHistoryParams". @@ -365,4 +365,4 @@ export const CHAT_HISTORY_SCOPE = "operator.read" as const export const CHAT_HISTORY_IDEMPOTENCY = "read-only" as const export const CHAT_HISTORY_TIMEOUT = {"policy":"caller"} as const export const CHAT_HISTORY_CAPABILITY = {"kind":"method-availability","name":"chat.history"} as const -export const CHAT_HISTORY_ERRORS = [{"code":"INVALID_REQUEST"},{"code":"UNAUTHORIZED"},{"code":"NOT_FOUND","semantics":"missing-non-webchat-session"},{"code":"UNAVAILABLE","retryable":true},{"code":"STORAGE_BUSY","details":true,"retryAfterMs":true,"retryable":true},{"code":"INTERNAL_ERROR"}] as const +export const CHAT_HISTORY_ERRORS = [{"code":"INVALID_REQUEST"},{"code":"UNAUTHORIZED"},{"code":"NOT_FOUND","semantics":"missing-non-webchat-session"},{"code":"UNAVAILABLE","retryable":true},{"code":"STORAGE_BUSY","details":true,"retryAfterMs":true,"retryable":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"}] as const diff --git a/opensquilla-webui/src/contracts/generated/v4/chatHistoryValidators.d.mts b/opensquilla-webui/src/contracts/generated/v4/chatHistoryValidators.d.mts index 8a042e959c..4cef9e6de9 100644 --- a/opensquilla-webui/src/contracts/generated/v4/chatHistoryValidators.d.mts +++ b/opensquilla-webui/src/contracts/generated/v4/chatHistoryValidators.d.mts @@ -1,5 +1,5 @@ // @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -// source-sha256: 8f6b9efa8c2ba853cee0e0de7c70a83288c811736e94a5d07b3aca7b01db2078 +// source-sha256: de253cb00a07e9955c452bd2b27bbcc761543148cc11ee1614b47e7b84e74fed export interface ContractValidator { (value: unknown): boolean diff --git a/opensquilla-webui/src/contracts/generated/v4/chatHistoryValidators.mjs b/opensquilla-webui/src/contracts/generated/v4/chatHistoryValidators.mjs index 58fb7de9db..9382cbcb3c 100644 --- a/opensquilla-webui/src/contracts/generated/v4/chatHistoryValidators.mjs +++ b/opensquilla-webui/src/contracts/generated/v4/chatHistoryValidators.mjs @@ -1,5 +1,5 @@ // @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -// source-sha256: 8f6b9efa8c2ba853cee0e0de7c70a83288c811736e94a5d07b3aca7b01db2078 +// source-sha256: de253cb00a07e9955c452bd2b27bbcc761543148cc11ee1614b47e7b84e74fed function __opensquillaAjvUcs2Length(str) { const len = str.length @@ -16,4 +16,4 @@ function __opensquillaAjvUcs2Length(str) { } return length } -"use strict";export const validateChatHistoryParams = validate20;const schema31 = {"$id":"urn:opensquilla:contract:v4:ChatHistoryParams","$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-history.schema.json#/$defs/ChatHistoryParams"};const schema34 = {"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.","type":"object","additionalProperties":true,"properties":{"sessionKey":{"type":["string","null"]},"limit":{"type":["integer","string","null"]},"before":{},"after":{},"includeCanonical":{"type":["boolean","string","number","null"]},"includeSummaries":{"type":["boolean","string","number","null"]}}};function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="urn:opensquilla:contract:v4:ChatHistoryParams" */;let vErrors = null;let errors = 0;const evaluated0 = validate20.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.sessionKey !== undefined){let data0 = data.sessionKey;if((typeof data0 !== "string") && (data0 !== null)){const err0 = {instancePath:instancePath+"/sessionKey",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-history.schema.json#/$defs/ChatHistoryParams/properties/sessionKey/type",keyword:"type",params:{type: schema34.properties.sessionKey.type},message:"must be string,null"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}if(data.limit !== undefined){let data1 = data.limit;if(((!(((typeof data1 == "number") && (!(data1 % 1) && !isNaN(data1))) && (isFinite(data1)))) && (typeof data1 !== "string")) && (data1 !== null)){const err1 = {instancePath:instancePath+"/limit",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-history.schema.json#/$defs/ChatHistoryParams/properties/limit/type",keyword:"type",params:{type: schema34.properties.limit.type},message:"must be integer,string,null"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}if(data.includeCanonical !== undefined){let data2 = data.includeCanonical;if((((typeof data2 !== "boolean") && (typeof data2 !== "string")) && (!((typeof data2 == "number") && (isFinite(data2))))) && (data2 !== null)){const err2 = {instancePath:instancePath+"/includeCanonical",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-history.schema.json#/$defs/ChatHistoryParams/properties/includeCanonical/type",keyword:"type",params:{type: schema34.properties.includeCanonical.type},message:"must be boolean,string,number,null"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data.includeSummaries !== undefined){let data3 = data.includeSummaries;if((((typeof data3 !== "boolean") && (typeof data3 !== "string")) && (!((typeof data3 == "number") && (isFinite(data3))))) && (data3 !== null)){const err3 = {instancePath:instancePath+"/includeSummaries",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-history.schema.json#/$defs/ChatHistoryParams/properties/includeSummaries/type",keyword:"type",params:{type: schema34.properties.includeSummaries.type},message:"must be boolean,string,number,null"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}}else {const err4 = {instancePath,schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-history.schema.json#/$defs/ChatHistoryParams/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}validate20.errors = vErrors;return errors === 0;}validate20.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};export const validateChatHistoryResult = validate40;const schema55 = {"$id":"urn:opensquilla:contract:v4:ChatHistoryResult","$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-history.schema.json#/$defs/ChatHistoryResult"};const schema38 = {"title":"chat.history result","type":"object","additionalProperties":true,"required":["messages","has_more","oldest_cursor","newest_cursor","history_scope","loaded_count","page_size","canonical_available","canonical_complete","compaction_summaries","turn_outcomes"],"properties":{"messages":{"type":"array","items":{"$ref":"#/$defs/ChatHistoryMessage"}},"has_more":{"type":"boolean"},"oldest_cursor":{"$ref":"#/$defs/NullableCursor"},"newest_cursor":{"$ref":"#/$defs/NullableCursor"},"history_scope":{"enum":["complete","latest_window","compacted"]},"loaded_count":{"type":"integer","minimum":0},"page_size":{"type":"integer","minimum":1,"maximum":200},"canonical_available":{"type":"boolean"},"canonical_complete":{"type":"boolean"},"compaction_summaries":{"type":"array","items":{"$ref":"#/$defs/CompactionSummary"}},"turn_outcomes":{"type":"array","items":{"$ref":"#/$defs/TurnOutcome"}}}};const schema45 = {"type":["string","null"]};const schema39 = {"description":"A projected transcript message. Role-specific activity, attachment, artifact, usage and provenance fields remain additive v4 extensions.","type":"object","additionalProperties":true,"properties":{"id":{"type":["string","integer","null"]},"message_id":{"type":["string","null"]},"role":{"type":"string"},"text":{"type":"string"},"timestamp":{"type":["number","string","null"]},"ts":{"type":["number","string","null"]},"transcript_id":{"type":["integer","string","null"]},"attachments":{"type":["array","null"],"items":{"type":"object","additionalProperties":true}},"artifacts":{"type":["array","null"],"items":{"type":"object","additionalProperties":true}},"tool_calls":{"type":["array","null"],"items":{}},"turn_context":{"type":["object","null"],"additionalProperties":true},"usage":{"type":["object","null"],"additionalProperties":true},"turn_usage":{"type":["object","null"],"additionalProperties":true},"provenance_kind":{"$ref":"#/$defs/NullableString"},"provenance_source_session_key":{"$ref":"#/$defs/NullableString"},"provenance_source_tool":{"$ref":"#/$defs/NullableString"},"pageContext":{"anyOf":[{"$ref":"#/$defs/PageContext"},{"type":"null"}]}}};const schema40 = {"type":["string","null"]};const schema43 = {"description":"Page references and user annotations only. Gateway normalization enforces UTF-8 byte limits and a 65536-byte serialized context limit; JSON Schema length bounds count characters. Page text and locator hints are untrusted context, not source identity or edit authority.","type":"object","additionalProperties":false,"properties":{"pagePath":{"type":["string","null"],"minLength":1,"maxLength":4096,"pattern":"\\S","description":"Logical HTML page within the referenced document. The Gateway validates the current collection and source path; this hint does not grant access."},"targetRef":{"type":["string","null"],"minLength":1,"maxLength":512,"pattern":"\\S","description":"Opaque page reference. The Gateway trims whitespace and enforces a 512-byte UTF-8 limit."},"resourceId":{"type":["string","null"],"minLength":1,"maxLength":512,"pattern":"\\S","description":"Opaque page reference. The Gateway trims whitespace and enforces a 512-byte UTF-8 limit."},"annotations":{"type":"array","maxItems":16,"items":{"$ref":"#/$defs/PageAnnotation"}}}};const schema44 = {"type":"object","additionalProperties":false,"required":["text"],"properties":{"text":{"type":"string","minLength":1,"maxLength":16384,"pattern":"\\S","description":"User annotation; must contain non-whitespace text and fit 16384 UTF-8 bytes."},"selectionText":{"type":["string","null"],"maxLength":16384,"description":"The Gateway enforces a 16384-byte UTF-8 limit."},"locatorHint":{"type":["string","null"],"maxLength":16384,"description":"The Gateway enforces a 16384-byte UTF-8 limit."}}};const func1 = __opensquillaAjvUcs2Length;const pattern4 = new RegExp("\\S", "u");function validate28(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate28.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){for(const key0 in data){if(!((((key0 === "pagePath") || (key0 === "targetRef")) || (key0 === "resourceId")) || (key0 === "annotations"))){const err0 = {instancePath,schemaPath:"#/additionalProperties",keyword:"additionalProperties",params:{additionalProperty: key0},message:"must NOT have additional properties"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}if(data.pagePath !== undefined){let data0 = data.pagePath;if((typeof data0 !== "string") && (data0 !== null)){const err1 = {instancePath:instancePath+"/pagePath",schemaPath:"#/properties/pagePath/type",keyword:"type",params:{type: schema43.properties.pagePath.type},message:"must be string,null"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}if(typeof data0 === "string"){if(func1(data0) > 4096){const err2 = {instancePath:instancePath+"/pagePath",schemaPath:"#/properties/pagePath/maxLength",keyword:"maxLength",params:{limit: 4096},message:"must NOT have more than 4096 characters"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(func1(data0) < 1){const err3 = {instancePath:instancePath+"/pagePath",schemaPath:"#/properties/pagePath/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(!pattern4.test(data0)){const err4 = {instancePath:instancePath+"/pagePath",schemaPath:"#/properties/pagePath/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}}if(data.targetRef !== undefined){let data1 = data.targetRef;if((typeof data1 !== "string") && (data1 !== null)){const err5 = {instancePath:instancePath+"/targetRef",schemaPath:"#/properties/targetRef/type",keyword:"type",params:{type: schema43.properties.targetRef.type},message:"must be string,null"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(typeof data1 === "string"){if(func1(data1) > 512){const err6 = {instancePath:instancePath+"/targetRef",schemaPath:"#/properties/targetRef/maxLength",keyword:"maxLength",params:{limit: 512},message:"must NOT have more than 512 characters"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(func1(data1) < 1){const err7 = {instancePath:instancePath+"/targetRef",schemaPath:"#/properties/targetRef/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(!pattern4.test(data1)){const err8 = {instancePath:instancePath+"/targetRef",schemaPath:"#/properties/targetRef/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}}if(data.resourceId !== undefined){let data2 = data.resourceId;if((typeof data2 !== "string") && (data2 !== null)){const err9 = {instancePath:instancePath+"/resourceId",schemaPath:"#/properties/resourceId/type",keyword:"type",params:{type: schema43.properties.resourceId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(typeof data2 === "string"){if(func1(data2) > 512){const err10 = {instancePath:instancePath+"/resourceId",schemaPath:"#/properties/resourceId/maxLength",keyword:"maxLength",params:{limit: 512},message:"must NOT have more than 512 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(func1(data2) < 1){const err11 = {instancePath:instancePath+"/resourceId",schemaPath:"#/properties/resourceId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(!pattern4.test(data2)){const err12 = {instancePath:instancePath+"/resourceId",schemaPath:"#/properties/resourceId/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}}if(data.annotations !== undefined){let data3 = data.annotations;if(Array.isArray(data3)){if(data3.length > 16){const err13 = {instancePath:instancePath+"/annotations",schemaPath:"#/properties/annotations/maxItems",keyword:"maxItems",params:{limit: 16},message:"must NOT have more than 16 items"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}const len0 = data3.length;for(let i0=0; i0 16384){const err16 = {instancePath:instancePath+"/annotations/" + i0+"/text",schemaPath:"#/$defs/PageAnnotation/properties/text/maxLength",keyword:"maxLength",params:{limit: 16384},message:"must NOT have more than 16384 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(func1(data5) < 1){const err17 = {instancePath:instancePath+"/annotations/" + i0+"/text",schemaPath:"#/$defs/PageAnnotation/properties/text/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(!pattern4.test(data5)){const err18 = {instancePath:instancePath+"/annotations/" + i0+"/text",schemaPath:"#/$defs/PageAnnotation/properties/text/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/annotations/" + i0+"/text",schemaPath:"#/$defs/PageAnnotation/properties/text/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data4.selectionText !== undefined){let data6 = data4.selectionText;if((typeof data6 !== "string") && (data6 !== null)){const err20 = {instancePath:instancePath+"/annotations/" + i0+"/selectionText",schemaPath:"#/$defs/PageAnnotation/properties/selectionText/type",keyword:"type",params:{type: schema44.properties.selectionText.type},message:"must be string,null"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(typeof data6 === "string"){if(func1(data6) > 16384){const err21 = {instancePath:instancePath+"/annotations/" + i0+"/selectionText",schemaPath:"#/$defs/PageAnnotation/properties/selectionText/maxLength",keyword:"maxLength",params:{limit: 16384},message:"must NOT have more than 16384 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}}if(data4.locatorHint !== undefined){let data7 = data4.locatorHint;if((typeof data7 !== "string") && (data7 !== null)){const err22 = {instancePath:instancePath+"/annotations/" + i0+"/locatorHint",schemaPath:"#/$defs/PageAnnotation/properties/locatorHint/type",keyword:"type",params:{type: schema44.properties.locatorHint.type},message:"must be string,null"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(typeof data7 === "string"){if(func1(data7) > 16384){const err23 = {instancePath:instancePath+"/annotations/" + i0+"/locatorHint",schemaPath:"#/$defs/PageAnnotation/properties/locatorHint/maxLength",keyword:"maxLength",params:{limit: 16384},message:"must NOT have more than 16384 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}}}else {const err24 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/$defs/PageAnnotation/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}}else {const err25 = {instancePath:instancePath+"/annotations",schemaPath:"#/properties/annotations/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}}else {const err26 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}validate28.errors = vErrors;return errors === 0;}validate28.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate27(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate27.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.id !== undefined){let data0 = data.id;if(((typeof data0 !== "string") && (!(((typeof data0 == "number") && (!(data0 % 1) && !isNaN(data0))) && (isFinite(data0))))) && (data0 !== null)){const err0 = {instancePath:instancePath+"/id",schemaPath:"#/properties/id/type",keyword:"type",params:{type: schema39.properties.id.type},message:"must be string,integer,null"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}if(data.message_id !== undefined){let data1 = data.message_id;if((typeof data1 !== "string") && (data1 !== null)){const err1 = {instancePath:instancePath+"/message_id",schemaPath:"#/properties/message_id/type",keyword:"type",params:{type: schema39.properties.message_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}if(data.role !== undefined){if(typeof data.role !== "string"){const err2 = {instancePath:instancePath+"/role",schemaPath:"#/properties/role/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data.text !== undefined){if(typeof data.text !== "string"){const err3 = {instancePath:instancePath+"/text",schemaPath:"#/properties/text/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.timestamp !== undefined){let data4 = data.timestamp;if(((!((typeof data4 == "number") && (isFinite(data4)))) && (typeof data4 !== "string")) && (data4 !== null)){const err4 = {instancePath:instancePath+"/timestamp",schemaPath:"#/properties/timestamp/type",keyword:"type",params:{type: schema39.properties.timestamp.type},message:"must be number,string,null"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.ts !== undefined){let data5 = data.ts;if(((!((typeof data5 == "number") && (isFinite(data5)))) && (typeof data5 !== "string")) && (data5 !== null)){const err5 = {instancePath:instancePath+"/ts",schemaPath:"#/properties/ts/type",keyword:"type",params:{type: schema39.properties.ts.type},message:"must be number,string,null"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.transcript_id !== undefined){let data6 = data.transcript_id;if(((!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))) && (typeof data6 !== "string")) && (data6 !== null)){const err6 = {instancePath:instancePath+"/transcript_id",schemaPath:"#/properties/transcript_id/type",keyword:"type",params:{type: schema39.properties.transcript_id.type},message:"must be integer,string,null"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.attachments !== undefined){let data7 = data.attachments;if((!(Array.isArray(data7))) && (data7 !== null)){const err7 = {instancePath:instancePath+"/attachments",schemaPath:"#/properties/attachments/type",keyword:"type",params:{type: schema39.properties.attachments.type},message:"must be array,null"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(Array.isArray(data7)){const len0 = data7.length;for(let i0=0; i0=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}if(data.page_size !== undefined){let data7 = data.page_size;if(!(((typeof data7 == "number") && (!(data7 % 1) && !isNaN(data7))) && (isFinite(data7)))){const err18 = {instancePath:instancePath+"/page_size",schemaPath:"#/properties/page_size/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if((typeof data7 == "number") && (isFinite(data7))){if(data7 > 200 || isNaN(data7)){const err19 = {instancePath:instancePath+"/page_size",schemaPath:"#/properties/page_size/maximum",keyword:"maximum",params:{comparison: "<=", limit: 200},message:"must be <= 200"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(data7 < 1 || isNaN(data7)){const err20 = {instancePath:instancePath+"/page_size",schemaPath:"#/properties/page_size/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}if(data.canonical_available !== undefined){if(typeof data.canonical_available !== "boolean"){const err21 = {instancePath:instancePath+"/canonical_available",schemaPath:"#/properties/canonical_available/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}if(data.canonical_complete !== undefined){if(typeof data.canonical_complete !== "boolean"){const err22 = {instancePath:instancePath+"/canonical_complete",schemaPath:"#/properties/canonical_complete/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.compaction_summaries !== undefined){let data10 = data.compaction_summaries;if(Array.isArray(data10)){const len1 = data10.length;for(let i1=0; i1 4096){const err2 = {instancePath:instancePath+"/pagePath",schemaPath:"#/properties/pagePath/maxLength",keyword:"maxLength",params:{limit: 4096},message:"must NOT have more than 4096 characters"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}if(func1(data0) < 1){const err3 = {instancePath:instancePath+"/pagePath",schemaPath:"#/properties/pagePath/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}if(!pattern4.test(data0)){const err4 = {instancePath:instancePath+"/pagePath",schemaPath:"#/properties/pagePath/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}}if(data.targetRef !== undefined){let data1 = data.targetRef;if((typeof data1 !== "string") && (data1 !== null)){const err5 = {instancePath:instancePath+"/targetRef",schemaPath:"#/properties/targetRef/type",keyword:"type",params:{type: schema43.properties.targetRef.type},message:"must be string,null"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}if(typeof data1 === "string"){if(func1(data1) > 512){const err6 = {instancePath:instancePath+"/targetRef",schemaPath:"#/properties/targetRef/maxLength",keyword:"maxLength",params:{limit: 512},message:"must NOT have more than 512 characters"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}if(func1(data1) < 1){const err7 = {instancePath:instancePath+"/targetRef",schemaPath:"#/properties/targetRef/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(!pattern4.test(data1)){const err8 = {instancePath:instancePath+"/targetRef",schemaPath:"#/properties/targetRef/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}}if(data.resourceId !== undefined){let data2 = data.resourceId;if((typeof data2 !== "string") && (data2 !== null)){const err9 = {instancePath:instancePath+"/resourceId",schemaPath:"#/properties/resourceId/type",keyword:"type",params:{type: schema43.properties.resourceId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}if(typeof data2 === "string"){if(func1(data2) > 512){const err10 = {instancePath:instancePath+"/resourceId",schemaPath:"#/properties/resourceId/maxLength",keyword:"maxLength",params:{limit: 512},message:"must NOT have more than 512 characters"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}if(func1(data2) < 1){const err11 = {instancePath:instancePath+"/resourceId",schemaPath:"#/properties/resourceId/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}if(!pattern4.test(data2)){const err12 = {instancePath:instancePath+"/resourceId",schemaPath:"#/properties/resourceId/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}}if(data.annotations !== undefined){let data3 = data.annotations;if(Array.isArray(data3)){if(data3.length > 16){const err13 = {instancePath:instancePath+"/annotations",schemaPath:"#/properties/annotations/maxItems",keyword:"maxItems",params:{limit: 16},message:"must NOT have more than 16 items"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}const len0 = data3.length;for(let i0=0; i0 16384){const err16 = {instancePath:instancePath+"/annotations/" + i0+"/text",schemaPath:"#/$defs/PageAnnotation/properties/text/maxLength",keyword:"maxLength",params:{limit: 16384},message:"must NOT have more than 16384 characters"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}if(func1(data5) < 1){const err17 = {instancePath:instancePath+"/annotations/" + i0+"/text",schemaPath:"#/$defs/PageAnnotation/properties/text/minLength",keyword:"minLength",params:{limit: 1},message:"must NOT have fewer than 1 characters"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}if(!pattern4.test(data5)){const err18 = {instancePath:instancePath+"/annotations/" + i0+"/text",schemaPath:"#/$defs/PageAnnotation/properties/text/pattern",keyword:"pattern",params:{pattern: "\\S"},message:"must match pattern \""+"\\S"+"\""};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}else {const err19 = {instancePath:instancePath+"/annotations/" + i0+"/text",schemaPath:"#/$defs/PageAnnotation/properties/text/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data4.selectionText !== undefined){let data6 = data4.selectionText;if((typeof data6 !== "string") && (data6 !== null)){const err20 = {instancePath:instancePath+"/annotations/" + i0+"/selectionText",schemaPath:"#/$defs/PageAnnotation/properties/selectionText/type",keyword:"type",params:{type: schema44.properties.selectionText.type},message:"must be string,null"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(typeof data6 === "string"){if(func1(data6) > 16384){const err21 = {instancePath:instancePath+"/annotations/" + i0+"/selectionText",schemaPath:"#/$defs/PageAnnotation/properties/selectionText/maxLength",keyword:"maxLength",params:{limit: 16384},message:"must NOT have more than 16384 characters"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}}if(data4.locatorHint !== undefined){let data7 = data4.locatorHint;if((typeof data7 !== "string") && (data7 !== null)){const err22 = {instancePath:instancePath+"/annotations/" + i0+"/locatorHint",schemaPath:"#/$defs/PageAnnotation/properties/locatorHint/type",keyword:"type",params:{type: schema44.properties.locatorHint.type},message:"must be string,null"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}if(typeof data7 === "string"){if(func1(data7) > 16384){const err23 = {instancePath:instancePath+"/annotations/" + i0+"/locatorHint",schemaPath:"#/$defs/PageAnnotation/properties/locatorHint/maxLength",keyword:"maxLength",params:{limit: 16384},message:"must NOT have more than 16384 characters"};if(vErrors === null){vErrors = [err23];}else {vErrors.push(err23);}errors++;}}}}else {const err24 = {instancePath:instancePath+"/annotations/" + i0,schemaPath:"#/$defs/PageAnnotation/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err24];}else {vErrors.push(err24);}errors++;}}}else {const err25 = {instancePath:instancePath+"/annotations",schemaPath:"#/properties/annotations/type",keyword:"type",params:{type: "array"},message:"must be array"};if(vErrors === null){vErrors = [err25];}else {vErrors.push(err25);}errors++;}}}else {const err26 = {instancePath,schemaPath:"#/type",keyword:"type",params:{type: "object"},message:"must be object"};if(vErrors === null){vErrors = [err26];}else {vErrors.push(err26);}errors++;}validate28.errors = vErrors;return errors === 0;}validate28.evaluated = {"props":true,"dynamicProps":false,"dynamicItems":false};function validate27(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){let vErrors = null;let errors = 0;const evaluated0 = validate27.evaluated;if(evaluated0.dynamicProps){evaluated0.props = undefined;}if(evaluated0.dynamicItems){evaluated0.items = undefined;}if(data && typeof data == "object" && !Array.isArray(data)){if(data.id !== undefined){let data0 = data.id;if(((typeof data0 !== "string") && (!(((typeof data0 == "number") && (!(data0 % 1) && !isNaN(data0))) && (isFinite(data0))))) && (data0 !== null)){const err0 = {instancePath:instancePath+"/id",schemaPath:"#/properties/id/type",keyword:"type",params:{type: schema39.properties.id.type},message:"must be string,integer,null"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}if(data.message_id !== undefined){let data1 = data.message_id;if((typeof data1 !== "string") && (data1 !== null)){const err1 = {instancePath:instancePath+"/message_id",schemaPath:"#/properties/message_id/type",keyword:"type",params:{type: schema39.properties.message_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}if(data.role !== undefined){if(typeof data.role !== "string"){const err2 = {instancePath:instancePath+"/role",schemaPath:"#/properties/role/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data.text !== undefined){if(typeof data.text !== "string"){const err3 = {instancePath:instancePath+"/text",schemaPath:"#/properties/text/type",keyword:"type",params:{type: "string"},message:"must be string"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.timestamp !== undefined){let data4 = data.timestamp;if(((!((typeof data4 == "number") && (isFinite(data4)))) && (typeof data4 !== "string")) && (data4 !== null)){const err4 = {instancePath:instancePath+"/timestamp",schemaPath:"#/properties/timestamp/type",keyword:"type",params:{type: schema39.properties.timestamp.type},message:"must be number,string,null"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.ts !== undefined){let data5 = data.ts;if(((!((typeof data5 == "number") && (isFinite(data5)))) && (typeof data5 !== "string")) && (data5 !== null)){const err5 = {instancePath:instancePath+"/ts",schemaPath:"#/properties/ts/type",keyword:"type",params:{type: schema39.properties.ts.type},message:"must be number,string,null"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.transcript_id !== undefined){let data6 = data.transcript_id;if(((!(((typeof data6 == "number") && (!(data6 % 1) && !isNaN(data6))) && (isFinite(data6)))) && (typeof data6 !== "string")) && (data6 !== null)){const err6 = {instancePath:instancePath+"/transcript_id",schemaPath:"#/properties/transcript_id/type",keyword:"type",params:{type: schema39.properties.transcript_id.type},message:"must be integer,string,null"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.attachments !== undefined){let data7 = data.attachments;if((!(Array.isArray(data7))) && (data7 !== null)){const err7 = {instancePath:instancePath+"/attachments",schemaPath:"#/properties/attachments/type",keyword:"type",params:{type: schema39.properties.attachments.type},message:"must be array,null"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}if(Array.isArray(data7)){const len0 = data7.length;for(let i0=0; i0=", limit: 0},message:"must be >= 0"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}}if(data.page_size !== undefined){let data7 = data.page_size;if(!(((typeof data7 == "number") && (!(data7 % 1) && !isNaN(data7))) && (isFinite(data7)))){const err18 = {instancePath:instancePath+"/page_size",schemaPath:"#/properties/page_size/type",keyword:"type",params:{type: "integer"},message:"must be integer"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}if((typeof data7 == "number") && (isFinite(data7))){if(data7 > 200 || isNaN(data7)){const err19 = {instancePath:instancePath+"/page_size",schemaPath:"#/properties/page_size/maximum",keyword:"maximum",params:{comparison: "<=", limit: 200},message:"must be <= 200"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}if(data7 < 1 || isNaN(data7)){const err20 = {instancePath:instancePath+"/page_size",schemaPath:"#/properties/page_size/minimum",keyword:"minimum",params:{comparison: ">=", limit: 1},message:"must be >= 1"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}}}if(data.canonical_available !== undefined){if(typeof data.canonical_available !== "boolean"){const err21 = {instancePath:instancePath+"/canonical_available",schemaPath:"#/properties/canonical_available/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err21];}else {vErrors.push(err21);}errors++;}}if(data.canonical_complete !== undefined){if(typeof data.canonical_complete !== "boolean"){const err22 = {instancePath:instancePath+"/canonical_complete",schemaPath:"#/properties/canonical_complete/type",keyword:"type",params:{type: "boolean"},message:"must be boolean"};if(vErrors === null){vErrors = [err22];}else {vErrors.push(err22);}errors++;}}if(data.compaction_summaries !== undefined){let data10 = data.compaction_summaries;if(Array.isArray(data10)){const len1 = data10.length;for(let i1=0; i1 HistoryPage: """Prefer canonical storage, then apply the legacy fallback policy.""" + effective_after = None if query.before is not None else query.after + canonical_failure: CanonicalHistoryReadError | None = None if query.include_canonical and self._canonical is not None: - # The Port deliberately decides which implementation failures are - # recoverable. Any exception that reaches here (for example a - # retryable storage-busy error) must remain visible to the adapter. - canonical_page = await self._canonical.read_canonical_page( - query.session_key, - limit=query.limit, - before=query.before, - after=query.after, - ) + try: + canonical_page = await self._canonical.read_canonical_page( + query.session_key, + limit=query.limit, + before=query.before, + after=effective_after, + ) + except CanonicalHistoryReadError as exc: + canonical_failure = exc + canonical_page = None if canonical_page is not None: return HistoryPage( entries=tuple(canonical_page.entries), @@ -108,12 +119,17 @@ async def read_page(self, query: SessionHistoryQuery) -> HistoryPage: transcript = tuple( await self._active.read_active_transcript(query.session_key) ) - entries, has_more = paginate_transcript( - transcript, - limit=query.limit, - before=query.before, - after=query.after, - ) + try: + entries, has_more = paginate_transcript( + transcript, + limit=query.limit, + before=query.before, + after=effective_after, + ) + except HistoryCursorInvalidatedError: + if canonical_failure is not None: + raise canonical_failure + raise return HistoryPage( entries=entries, has_more=has_more, @@ -126,13 +142,19 @@ def cursor_for_entry(entry: object) -> HistoryCursor | None: """Return the stable integer cursor used by the history Port.""" created_at = getattr(entry, "created_at", None) - stable_id = getattr(entry, "id", None) or getattr(entry, "message_id", None) - if created_at in {None, ""} or stable_id in {None, ""}: - return None - try: - return int(cast(Any, created_at)), int(cast(Any, stable_id)) - except (TypeError, ValueError): + stable_id = getattr(entry, "id", None) + if ( + not isinstance(created_at, int) + or isinstance(created_at, bool) + or not isinstance(stable_id, int) + or isinstance(stable_id, bool) + or created_at < 0 + or stable_id < 0 + or created_at > HISTORY_CURSOR_MAX_INTEGER + or stable_id > HISTORY_CURSOR_MAX_INTEGER + ): return None + return created_at, stable_id def paginate_transcript( @@ -144,26 +166,33 @@ def paginate_transcript( ) -> tuple[tuple[object, ...], bool]: """Apply the current active-transcript keyset policy. - A missing cursor is treated as an unpositioned read, matching the legacy - handler. When both cursors are supplied, ``before`` wins. The caller - supplies a positive ``limit`` after v4 compatibility normalization. + Only an absent cursor is an unpositioned read. When both cursors are + supplied, ``before`` wins. A parsed cursor that does not identify an entry + raises instead of silently returning the latest window. """ rows = tuple(entries) - if not rows: - return (), False - - before_index = _cursor_index(rows, before) - if before_index is not None: + if before is not None: + before_index = _cursor_index(rows, before) + if before_index is None: + raise HistoryCursorInvalidatedError( + "history cursor no longer anchors this session" + ) start = max(0, before_index - limit) return rows[start:before_index], start > 0 - after_index = _cursor_index(rows, after) - if after_index is not None: + if after is not None: + after_index = _cursor_index(rows, after) + if after_index is None: + raise HistoryCursorInvalidatedError( + "history cursor no longer anchors this session" + ) start = min(len(rows), after_index + 1) end = min(len(rows), start + limit) return rows[start:end], end < len(rows) + if not rows: + return (), False if len(rows) <= limit: return rows, False return rows[-limit:], True @@ -180,6 +209,7 @@ def _cursor_index(entries: Sequence[object], cursor: HistoryCursor | None) -> in __all__ = [ "ActiveHistoryReader", + "CanonicalHistoryReadError", "CanonicalHistoryReader", "HistoryCursor", "HistoryPage", diff --git a/src/opensquilla/contracts/generated/v4/chat_history.py b/src/opensquilla/contracts/generated/v4/chat_history.py index 0080f46a0b..eb7b5cbe01 100644 --- a/src/opensquilla/contracts/generated/v4/chat_history.py +++ b/src/opensquilla/contracts/generated/v4/chat_history.py @@ -1,5 +1,5 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# source-sha256: 8f6b9efa8c2ba853cee0e0de7c70a83288c811736e94a5d07b3aca7b01db2078 +# source-sha256: de253cb00a07e9955c452bd2b27bbcc761543148cc11ee1614b47e7b84e74fed # ruff: noqa # generated by datamodel-codegen: @@ -60,7 +60,7 @@ class NullableCursor(RootModel[StrictStr | None]): class ChatHistoryParams(BaseModel): """ - All fields are optional for v4 compatibility. A null or absent sessionKey selects the canonical default WebChat session. Unknown fields are ignored. + 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. """ model_config = ConfigDict( @@ -269,7 +269,7 @@ class ChatHistoryResponseFrame( class OpensquillaChatHistoryContract(BaseModel): """ - 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. + 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. """ model_config = ConfigDict( diff --git a/src/opensquilla/contracts/generated/v4/chat_history_metadata.py b/src/opensquilla/contracts/generated/v4/chat_history_metadata.py index 67e569410d..d6176b5a3a 100644 --- a/src/opensquilla/contracts/generated/v4/chat_history_metadata.py +++ b/src/opensquilla/contracts/generated/v4/chat_history_metadata.py @@ -1,5 +1,5 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# source-sha256: 8f6b9efa8c2ba853cee0e0de7c70a83288c811736e94a5d07b3aca7b01db2078 +# source-sha256: de253cb00a07e9955c452bd2b27bbcc761543148cc11ee1614b47e7b84e74fed # ruff: noqa from typing import Final @@ -10,4 +10,4 @@ CHAT_HISTORY_IDEMPOTENCY: Final = 'read-only' CHAT_HISTORY_TIMEOUT: Final = {'policy': 'caller'} CHAT_HISTORY_CAPABILITY: Final = {'kind': 'method-availability', 'name': 'chat.history'} -CHAT_HISTORY_ERRORS: Final = [{'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'NOT_FOUND', 'semantics': 'missing-non-webchat-session'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'STORAGE_BUSY', 'retryable': True, 'retryAfterMs': True, 'details': True}, {'code': 'INTERNAL_ERROR'}] +CHAT_HISTORY_ERRORS: Final = [{'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'NOT_FOUND', 'semantics': 'missing-non-webchat-session'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'STORAGE_BUSY', 'retryable': True, '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'}] diff --git a/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py b/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py index c5bbce5586..c361beca23 100644 --- a/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py +++ b/src/opensquilla/contracts/generated/v4/gateway_contract_registry.py @@ -1,5 +1,5 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# sources-sha256: 40f348cb685bc2449ebd75cad5eb3aacde0e0e3e8498939001c0eeed10dccc1b +# sources-sha256: 942492165808d7e5637b218bee68d593e17129ed295061dc0db222dd80bf264d # generator-sha256: 0e0b513d7844d926e5f2c6065e64e4b523a1f8df2023317327ee6e05bfa29038 # ruff: noqa @@ -1264,7 +1264,7 @@ class GatewayEventContract: idempotency='read-only', timeout={'policy': 'caller'}, capability={'kind': 'method-availability', 'name': 'chat.history'}, - errors=({'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'NOT_FOUND', 'semantics': 'missing-non-webchat-session'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'STORAGE_BUSY', 'details': True, 'retryAfterMs': True, 'retryable': True}, {'code': 'INTERNAL_ERROR'}), + errors=({'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'NOT_FOUND', 'semantics': 'missing-non-webchat-session'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'STORAGE_BUSY', 'details': True, 'retryAfterMs': True, 'retryable': 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'}), protocol='opensquilla-websocket-json', wire_version=4, request_model=_chat_history_request_model, diff --git a/src/opensquilla/gateway/adapters/session_history.py b/src/opensquilla/gateway/adapters/session_history.py index 4fa830d1e1..459a6b736d 100644 --- a/src/opensquilla/gateway/adapters/session_history.py +++ b/src/opensquilla/gateway/adapters/session_history.py @@ -20,48 +20,61 @@ import structlog from opensquilla.application.session_history import ( - HistoryCursor, + CanonicalHistoryReadError, HistoryPage, SessionHistoryApplication, + cursor_for_entry, paginate_transcript, ) from opensquilla.chat.flattened_tool_markers import ( has_flattened_used_tool_line, is_flattened_tool_result_dump, ) +from opensquilla.history_cursor import ( + HISTORY_CURSOR_MAX_INTEGER, + HistoryCursor, + HistoryCursorInvalidatedError, + HistoryCursorInvalidError, +) from opensquilla.session.storage import StorageBusyError log = structlog.get_logger(__name__) +_MAX_HISTORY_CURSOR_INTEGER_TEXT = str(HISTORY_CURSOR_MAX_INTEGER) def parse_history_cursor(value: object) -> HistoryCursor | None: - """Parse the legacy ``created_at|entry_id`` cursor without raising. - - The v4 handler historically treated an absent, empty, or malformed - cursor as an unpositioned read. Keeping that conversion in the adapter - means the application layer never needs to know the wire representation. - """ + """Parse ``created_at|entry_id`` while retaining null/blank compatibility.""" - raw = str(value or "").strip() - if not raw or "|" not in raw: + if value is None: return None - created_at, stable_id = raw.split("|", 1) - try: - return int(created_at), int(stable_id) - except ValueError: + raw = str(value).strip() + if not raw: return None - - -def _cursor_text(entry: object | None) -> str | None: - """Render an entry cursor for compatibility reads inside this adapter.""" - - if entry is None: - return None - created_at = getattr(entry, "created_at", "") - stable_id = getattr(entry, "id", None) or getattr(entry, "message_id", "") - if created_at in {None, ""} or stable_id in {None, ""}: - return None - return f"{created_at}|{stable_id}" + if raw.count("|") != 1: + raise HistoryCursorInvalidError( + "history cursor must use the created_at|id integer format" + ) + created_at, stable_id = raw.split("|", 1) + if not all( + component.isascii() and component.isdecimal() + for component in (created_at, stable_id) + ): + raise HistoryCursorInvalidError( + "history cursor must use the created_at|id integer format" + ) + normalized = tuple( + component.lstrip("0") or "0" for component in (created_at, stable_id) + ) + if any( + len(component) > len(_MAX_HISTORY_CURSOR_INTEGER_TEXT) + or ( + len(component) == len(_MAX_HISTORY_CURSOR_INTEGER_TEXT) + and component > _MAX_HISTORY_CURSOR_INTEGER_TEXT + ) + for component in normalized + ): + raise HistoryCursorInvalidError("history cursor integers are out of range") + return int(normalized[0]), int(normalized[1]) def canonical_page_parts(page: object) -> tuple[list[object], bool, bool]: @@ -113,9 +126,9 @@ async def read_canonical_page( ) -> HistoryPage | None: """Read canonical history, returning ``None`` only when unavailable. - Non-retryable canonical failures historically fell back to the active - transcript. ``StorageBusyError`` is intentionally preserved so the - dispatcher can return its existing retryable error envelope. + Unexpected canonical failures are wrapped so active fallback can be + attempted without later misreporting a canonical-only anchor as stale. + Busy and cursor failures remain explicit. """ page_getter = getattr(self._manager, "get_canonical_transcript_page", None) @@ -134,10 +147,16 @@ async def read_canonical_page( canonical_available=True, canonical_complete=canonical_complete, ) - except StorageBusyError: + except ( + StorageBusyError, + HistoryCursorInvalidError, + HistoryCursorInvalidatedError, + ): raise - except Exception: # noqa: BLE001 - preserve legacy active fallback - return None + except Exception as exc: # noqa: BLE001 - preserve active fallback + raise CanonicalHistoryReadError( + "canonical history projection failed" + ) from exc getter = getattr(self._manager, "get_canonical_transcript", None) if callable(getter): @@ -155,10 +174,16 @@ async def read_canonical_page( canonical_available=True, canonical_complete=True, ) - except StorageBusyError: + except ( + StorageBusyError, + HistoryCursorInvalidError, + HistoryCursorInvalidatedError, + ): raise - except Exception: # noqa: BLE001 - preserve legacy active fallback - return None + except Exception as exc: # noqa: BLE001 - preserve active fallback + raise CanonicalHistoryReadError( + "canonical history projection failed" + ) from exc return None async def read_active_transcript(self, session_key: str) -> Sequence[object]: @@ -203,8 +228,8 @@ async def load_legacy_tool_projection_context( previous_entry = None next_entry = None - oldest_cursor = parse_history_cursor(_cursor_text(entries[0])) - newest_cursor = parse_history_cursor(_cursor_text(entries[-1])) + oldest_cursor = cursor_for_entry(entries[0]) + newest_cursor = cursor_for_entry(entries[-1]) if _needs_legacy_tool_lookbehind(entries[0]) and oldest_cursor is not None: try: @@ -226,7 +251,7 @@ async def load_legacy_tool_projection_context( return None, None if candidates: candidate = candidates[-1] - candidate_cursor = parse_history_cursor(_cursor_text(candidate)) + candidate_cursor = cursor_for_entry(candidate) if candidate_cursor is not None and candidate_cursor < oldest_cursor: previous_entry = candidate @@ -250,7 +275,7 @@ async def load_legacy_tool_projection_context( return None, None if candidates: candidate = candidates[0] - candidate_cursor = parse_history_cursor(_cursor_text(candidate)) + candidate_cursor = cursor_for_entry(candidate) if candidate_cursor is not None and candidate_cursor > newest_cursor: next_entry = candidate return previous_entry, next_entry diff --git a/src/opensquilla/gateway/adapters/session_history_projection.py b/src/opensquilla/gateway/adapters/session_history_projection.py index e6184a80d5..b64098a57c 100644 --- a/src/opensquilla/gateway/adapters/session_history_projection.py +++ b/src/opensquilla/gateway/adapters/session_history_projection.py @@ -16,7 +16,7 @@ import structlog -from opensquilla.application.session_history import SessionHistoryQuery +from opensquilla.application.session_history import SessionHistoryQuery, cursor_for_entry from opensquilla.artifact_session import ( ArtifactSessionService, MutationAttempt, @@ -29,7 +29,7 @@ parse_history_cursor, ) from opensquilla.gateway.adapters.turn_admission import webchat_session_key -from opensquilla.gateway.rpc.registry import RpcContext, RpcUnavailableError +from opensquilla.gateway.rpc.registry import RpcContext, RpcHandlerError, RpcUnavailableError from opensquilla.gateway.session_services import get_session_lock, get_session_storage from opensquilla.gateway.terminal_activity import ( is_usage_accounting_barrier, @@ -38,6 +38,10 @@ terminal_activity_snapshot, usage_barrier_replay_proof, ) +from opensquilla.history_cursor import ( + HistoryCursorInvalidatedError, + HistoryCursorInvalidError, +) from opensquilla.session.storage import StorageBusyError, bounded_interactive_storage_reads from opensquilla.session.terminal_reply import build_terminal_reply from opensquilla.turn_outcome_projection import ( @@ -488,10 +492,10 @@ def with_ledger_facts( def _chat_history_cursor(entry: object | None) -> str | None: if entry is None: return None - created_at = getattr(entry, "created_at", "") - stable_id = getattr(entry, "id", None) or getattr(entry, "message_id", "") - if created_at in {None, ""} or stable_id in {None, ""}: + cursor = cursor_for_entry(entry) + if cursor is None: return None + created_at, stable_id = cursor return f"{created_at}|{stable_id}" @@ -751,11 +755,22 @@ async def read_chat_history_v4(params: dict | None, ctx: RpcContext) -> dict: mgr = _require_chat_session_manager(ctx) history_adapter = SessionHistoryStorageAdapter(mgr) history_application = history_adapter.application() + try: + parsed_before = parse_history_cursor(before) + parsed_after = ( + None if parsed_before is not None else parse_history_cursor(after) + ) + except HistoryCursorInvalidError as exc: + raise RpcHandlerError( + "HISTORY_CURSOR_INVALID", + "The history cursor is invalid. Reload history from the latest page.", + ) from exc + history_query = SessionHistoryQuery( session_key=session_key, limit=limit, - before=parse_history_cursor(before), - after=parse_history_cursor(after), + before=parsed_before, + after=parsed_after, include_canonical=include_canonical, ) @@ -829,7 +844,19 @@ async def _load_page() -> tuple[ finally: if acquired: history_lock.release() - except KeyError: + except HistoryCursorInvalidatedError as exc: + raise RpcHandlerError( + "HISTORY_CURSOR_INVALIDATED", + "The history cursor no longer belongs to this session. " + "Reload from the latest page.", + ) from exc + except KeyError as exc: + if parsed_before is not None or parsed_after is not None: + raise RpcHandlerError( + "HISTORY_CURSOR_INVALIDATED", + "The history cursor no longer belongs to this session. " + "Reload from the latest page.", + ) from exc if _is_webchat_session_key(session_key): return _empty_chat_history_payload(limit) raise @@ -838,6 +865,15 @@ async def _load_page() -> tuple[ session_key, include_summaries=include_summaries, ) + oldest_cursor = _chat_history_cursor(page_entries[0]) if page_entries else None + newest_cursor = _chat_history_cursor(page_entries[-1]) if page_entries else None + continuation_cursor = newest_cursor if parsed_after is not None else oldest_cursor + # Older archives may contain rows created before original integer ids were + # preserved. Keep those rows visible, but do not advertise an unusable + # pagination boundary. + if has_more and continuation_cursor is None: + has_more = False + if summaries: history_scope = "compacted" elif has_more: @@ -862,8 +898,8 @@ async def _load_page() -> tuple[ session_key=session_key, ), "has_more": has_more, - "oldest_cursor": _chat_history_cursor(page_entries[0]) if page_entries else None, - "newest_cursor": _chat_history_cursor(page_entries[-1]) if page_entries else None, + "oldest_cursor": oldest_cursor, + "newest_cursor": newest_cursor, "history_scope": history_scope, "loaded_count": len(page_entries), "page_size": limit, diff --git a/src/opensquilla/history_cursor.py b/src/opensquilla/history_cursor.py new file mode 100644 index 0000000000..a718912cfd --- /dev/null +++ b/src/opensquilla/history_cursor.py @@ -0,0 +1,23 @@ +"""Shared history cursor types and failures.""" + +from __future__ import annotations + +type HistoryCursor = tuple[int, int] + +HISTORY_CURSOR_MAX_INTEGER = (1 << 63) - 1 + + +class HistoryCursorInvalidError(ValueError): + """Raised when a non-empty cursor is not valid wire input.""" + + +class HistoryCursorInvalidatedError(RuntimeError): + """Raised when a parsed cursor does not anchor the requested session.""" + + +__all__ = [ + "HISTORY_CURSOR_MAX_INTEGER", + "HistoryCursor", + "HistoryCursorInvalidError", + "HistoryCursorInvalidatedError", +] diff --git a/src/opensquilla/session/storage.py b/src/opensquilla/session/storage.py index bbf1b0b641..226602ff19 100644 --- a/src/opensquilla/session/storage.py +++ b/src/opensquilla/session/storage.py @@ -28,6 +28,7 @@ from typing import TYPE_CHECKING, Any, Concatenate, cast from opensquilla.compat import aiosqlite +from opensquilla.history_cursor import HistoryCursorInvalidatedError from opensquilla.session.attachment_manifest import preserve_attachment_occurrence_ids from opensquilla.session.cost_rollup import rollup_cost_source from opensquilla.session.goals import ( @@ -14553,28 +14554,6 @@ async def requeue_steer_recovery_task( ) return changed > 0 - async def _canonical_transcript_cursor_exists( - self, - session_id: str, - cursor: tuple[int, int], - ) -> bool: - created_at, entry_id = cursor - sql = """ - SELECT 1 - FROM transcript_entries - WHERE session_id = ? AND created_at = ? AND id = ? - UNION ALL - SELECT 1 - FROM compacted_transcript_entries - WHERE session_id = ? AND created_at = ? AND original_entry_id = ? - LIMIT 1 - """ - async with self.conn.execute( - sql, - (session_id, created_at, entry_id, session_id, created_at, entry_id), - ) as cur: - return await cur.fetchone() is not None - @_serialized_read async def get_canonical_transcript_page( self, @@ -14588,29 +14567,39 @@ async def get_canonical_transcript_page( Each source CTE is bounded to ``limit + 1`` rows and both are merged in one SQLite read snapshot. ``before`` keeps its historical precedence - over ``after`` when both cursors exist; an unknown cursor is ignored, - matching the legacy list-pagination path. + over ``after`` when both cursors exist. A supplied cursor must identify + an anchor in this session; missing, foreign, or deleted anchors fail + instead of becoming an unpositioned latest read. """ page_size = max(1, int(limit)) fetch_size = page_size + 1 - resolved_before = before - if resolved_before is not None and not await self._canonical_transcript_cursor_exists( - session_id, - resolved_before, - ): - resolved_before = None - - resolved_after = None - if resolved_before is None and after is not None: - if await self._canonical_transcript_cursor_exists(session_id, after): - resolved_after = after - - cursor = resolved_before or resolved_after - ascending = resolved_after is not None + cursor = before + ascending = False + if cursor is None and after is not None: + cursor = after + ascending = True comparator = ">" if ascending else "<" direction = "ASC" if ascending else "DESC" + anchor_params: list[Any] = [] + anchor_sql = "SELECT 1 AS present" + if cursor is not None: + created_at, entry_id = cursor + anchor_sql = """ + SELECT 1 AS present + FROM transcript_entries + WHERE session_id = ? AND created_at = ? AND id = ? + UNION ALL + SELECT 1 AS present + FROM compacted_transcript_entries + WHERE session_id = ? AND created_at = ? AND original_entry_id = ? + LIMIT 1 + """ + anchor_params.extend( + (session_id, created_at, entry_id, session_id, created_at, entry_id) + ) + active_params: list[Any] = [session_id] active_cursor_clause = "" if cursor is not None: @@ -14632,7 +14621,10 @@ async def get_canonical_transcript_page( archived_params.extend((created_at, created_at, entry_id)) archived_params.append(fetch_size) sql = f""" - WITH active_page AS ( + WITH cursor_anchor AS ( + {anchor_sql} + ), + active_page AS ( SELECT id, session_id, @@ -14656,6 +14648,7 @@ async def get_canonical_transcript_page( schema_version FROM transcript_entries WHERE session_id = ? + AND EXISTS (SELECT 1 FROM cursor_anchor) {active_cursor_clause} ORDER BY created_at {direction}, id {direction} LIMIT ? @@ -14684,6 +14677,7 @@ async def get_canonical_transcript_page( schema_version FROM compacted_transcript_entries WHERE session_id = ? + AND EXISTS (SELECT 1 FROM cursor_anchor) {archived_cursor_clause} ORDER BY created_at {direction}, @@ -14695,22 +14689,43 @@ async def get_canonical_transcript_page( SELECT * FROM active_page UNION ALL SELECT * FROM archived_page + ), + page AS ( + SELECT merged.*, 1 AS _page_row + FROM merged + ORDER BY created_at {direction}, id {direction} + LIMIT ? + ), + cursor_status AS ( + SELECT EXISTS (SELECT 1 FROM cursor_anchor) AS is_valid ) - SELECT * - FROM merged - ORDER BY created_at {direction}, id {direction} - LIMIT ? + SELECT page.*, cursor_status.is_valid AS _cursor_valid + FROM cursor_status + LEFT JOIN page ON cursor_status.is_valid = 1 + ORDER BY page.created_at {direction}, page.id {direction} """ - # Both sources must be read by one SQLite statement. A compaction moves - # rows from transcript_entries into compacted_transcript_entries inside - # one transaction; separate SELECT statements could otherwise observe - # opposite sides of that move and duplicate or omit canonical rows. - params = [*active_params, *archived_params, fetch_size] + # Cursor membership and both transcript sources share one SQLite + # statement, so a concurrent reset, delete, or compaction lands wholly + # before or after this snapshot. + params = [*anchor_params, *active_params, *archived_params, fetch_size] async with self.conn.execute(sql, params) as cur: rows = await cur.fetchall() - entries = [TranscriptEntry(**_deserialize_row(dict(row))) for row in rows] + if not rows or not bool(rows[0]["_cursor_valid"]): + raise HistoryCursorInvalidatedError( + "history cursor no longer anchors this session" + ) + + entry_rows: list[dict[str, Any]] = [] + for row in rows: + payload = dict(row) + payload.pop("_cursor_valid", None) + page_row = payload.pop("_page_row", None) + if page_row is not None: + entry_rows.append(payload) + + entries = [TranscriptEntry(**_deserialize_row(row)) for row in entry_rows] has_more = len(entries) > page_size entries = entries[:page_size] if not ascending: diff --git a/tests/test_application/test_session_history.py b/tests/test_application/test_session_history.py index 01d6d66e31..d2e96cc4a6 100644 --- a/tests/test_application/test_session_history.py +++ b/tests/test_application/test_session_history.py @@ -8,11 +8,13 @@ import pytest from opensquilla.application.session_history import ( + CanonicalHistoryReadError, HistoryPage, SessionHistoryApplication, SessionHistoryQuery, paginate_transcript, ) +from opensquilla.history_cursor import HistoryCursorInvalidatedError def entry(index: int) -> SimpleNamespace: @@ -92,7 +94,7 @@ async def test_canonical_page_is_preferred_and_metadata_is_normalized() -> None: "session_key": "agent:main:webchat:history", "limit": 2, "before": (3, 3), - "after": (1, 1), + "after": None, } ] @@ -169,11 +171,32 @@ def test_paginate_transcript_preserves_latest_window_and_after_cursor() -> None: latest, latest_more = paginate_transcript(rows, limit=2) forward, forward_more = paginate_transcript(rows, limit=2, after=(2, 2)) - missing, missing_more = paginate_transcript(rows, limit=2, after=(99, 99)) assert [getattr(row, "id") for row in latest] == [4, 5] assert latest_more is True assert [getattr(row, "id") for row in forward] == [3, 4] assert forward_more is True - assert [getattr(row, "id") for row in missing] == [4, 5] - assert missing_more is True + with pytest.raises(HistoryCursorInvalidatedError): + paginate_transcript(rows, limit=2, after=(99, 99)) + + +@pytest.mark.asyncio +async def test_canonical_failure_is_not_misreported_as_stale_by_active_fallback() -> None: + active = ActivePort([entry(1)]) + failure = CanonicalHistoryReadError("canonical projection failed") + + class BrokenCanonical(CanonicalPort): + async def read_canonical_page(self, *args: Any, **kwargs: Any) -> HistoryPage | None: + raise failure + + app = SessionHistoryApplication(active=active, canonical=BrokenCanonical()) + with pytest.raises(CanonicalHistoryReadError) as caught: + await app.read_page( + SessionHistoryQuery( + session_key="agent:main:webchat:history", + limit=1, + before=(2, 2), + ) + ) + + assert caught.value is failure diff --git a/tests/test_gateway/test_chat_history_characterization.py b/tests/test_gateway/test_chat_history_characterization.py index 77e8135a56..340764f961 100644 --- a/tests/test_gateway/test_chat_history_characterization.py +++ b/tests/test_gateway/test_chat_history_characterization.py @@ -263,7 +263,7 @@ async def test_chat_history_request_cases_preserve_v4_wire_behavior(request_case assert manager.canonical_calls == [] if request_case == "request.before-after": assert manager.canonical_calls[0]["before"] == (3, 3) - assert manager.canonical_calls[0]["after"] == (1, 1) + assert manager.canonical_calls[0]["after"] is None if request_case == "request.default-null": assert manager.active_calls == ["agent:main:webchat:default"] diff --git a/tests/test_gateway/test_rpc_chat_history.py b/tests/test_gateway/test_rpc_chat_history.py index bddb2c22b4..2edab0d42e 100644 --- a/tests/test_gateway/test_rpc_chat_history.py +++ b/tests/test_gateway/test_rpc_chat_history.py @@ -15,6 +15,7 @@ from opensquilla.gateway.adapters import session_history_projection from opensquilla.gateway.rpc import RpcContext, get_dispatcher from opensquilla.gateway.rpc_chat import _handle_chat_history +from opensquilla.history_cursor import HistoryCursorInvalidatedError from opensquilla.session.manager import SessionManager from opensquilla.session.models import ( AgentTaskRecord, @@ -153,6 +154,41 @@ async def test_chat_history_returns_pagination_metadata_with_legacy_messages() - assert result["canonical_complete"] is True +@pytest.mark.asyncio +async def test_chat_history_keeps_legacy_null_id_rows_without_an_unusable_cursor() -> None: + entry = TranscriptEntry( + id=None, + session_id="legacy", + session_key="agent:main:webchat:legacy", + role="user", + content="legacy row", + created_at=2, + message_id="legacy-row", + ) + manager = _FakePagedSessionManager( + [entry], + page={ + "entries": [entry], + "has_more": True, + "canonical_complete": False, + }, + ) + + result = await _handle_chat_history( + {"sessionKey": entry.session_key, "limit": 1}, + RpcContext( + conn_id="test", + principal=SimpleNamespace(role="operator"), + session_manager=manager, + ), + ) + + assert [message["message_id"] for message in result["messages"]] == ["legacy-row"] + assert result["has_more"] is False + assert result["oldest_cursor"] is None + assert result["newest_cursor"] is None + + @pytest.mark.asyncio async def test_chat_history_projects_parallel_legacy_activity_on_incomplete_page() -> None: tool_entry = TranscriptEntry( @@ -1663,6 +1699,44 @@ async def test_chat_history_before_cursor_returns_older_page() -> None: assert result["newest_cursor"] == "3|3" +@pytest.mark.asyncio +async def test_chat_history_keeps_blank_cursor_compatibility_and_before_precedence() -> None: + manager = _FakePagedSessionManager( + [_entry(4)], + page=SimpleNamespace( + entries=[_entry(2), _entry(3)], + has_more=True, + canonical_complete=True, + ), + ) + + await _handle_chat_history( + { + "sessionKey": "agent:main:webchat:test", + "before": "4|4", + "after": "malformed-but-ignored", + }, + RpcContext( + conn_id="test", + principal=SimpleNamespace(role="operator"), + session_manager=manager, + ), + ) + await _handle_chat_history( + {"sessionKey": "agent:main:webchat:test", "before": "", "after": None}, + RpcContext( + conn_id="test", + principal=SimpleNamespace(role="operator"), + session_manager=manager, + ), + ) + + assert manager.page_calls[0][1]["before"] == (4, 4) + assert manager.page_calls[0][1]["after"] is None + assert manager.page_calls[1][1]["before"] is None + assert manager.page_calls[1][1]["after"] is None + + @pytest.mark.asyncio async def test_chat_history_uses_canonical_transcript_when_available() -> None: active_entries = [_entry(3)] @@ -1920,6 +1994,49 @@ def get_session_lock(self, key: str) -> asyncio.Lock: assert response.error.details["resource"] == "session_mutation_lock" +@pytest.mark.asyncio +async def test_chat_history_cursor_failures_have_stable_wire_codes() -> None: + cases = ( + ( + {"before": "malformed"}, + _FakeSessionManager([_entry(1)], canonical_entries=[_entry(1)]), + "HISTORY_CURSOR_INVALID", + ), + ( + {"after": "1|1"}, + _FakePagedSessionManager( + [_entry(1)], + page_exception=HistoryCursorInvalidatedError("anchor missing"), + ), + "HISTORY_CURSOR_INVALIDATED", + ), + ) + + for index, (cursor, manager, expected) in enumerate(cases): + response = await get_dispatcher().dispatch( + f"history-cursor-{index}", + "chat.history", + { + "sessionKey": "agent:main:webchat:test", + "includeSummaries": False, + **cursor, + }, + RpcContext( + conn_id="test", + principal=SimpleNamespace( + role="operator", + scopes=frozenset({"operator.read"}), + ), + session_manager=manager, + ), + ) + + assert response.ok is False + assert response.error is not None + assert response.error.code == expected + assert response.error.retryable is False + + @pytest.mark.asyncio async def test_chat_history_keeps_explicit_active_transcript_view_compatible() -> None: mgr = _FakePagedSessionManager( diff --git a/tests/test_gateway/test_session_history_adapter.py b/tests/test_gateway/test_session_history_adapter.py index afb631c1a0..745fb14268 100644 --- a/tests/test_gateway/test_session_history_adapter.py +++ b/tests/test_gateway/test_session_history_adapter.py @@ -7,12 +7,19 @@ import pytest -from opensquilla.application.session_history import SessionHistoryQuery +from opensquilla.application.session_history import ( + CanonicalHistoryReadError, + SessionHistoryQuery, +) from opensquilla.gateway.adapters.session_history import ( SessionHistoryStorageAdapter, canonical_page_parts, parse_history_cursor, ) +from opensquilla.history_cursor import ( + HistoryCursorInvalidatedError, + HistoryCursorInvalidError, +) from opensquilla.session.storage import StorageBusyError @@ -26,14 +33,32 @@ def row(index: int, *, role: str = "user", content: str | None = None) -> Simple ) -def test_parse_history_cursor_keeps_legacy_unpositioned_cases() -> None: +def test_parse_history_cursor_keeps_null_and_blank_unpositioned() -> None: assert parse_history_cursor(None) is None assert parse_history_cursor("") is None - assert parse_history_cursor("not-a-cursor") is None - assert parse_history_cursor("1|not-an-int") is None + assert parse_history_cursor(" ") is None assert parse_history_cursor(" 2|7 ") == (2, 7) +@pytest.mark.parametrize( + "value", + [ + "not-a-cursor", + "1|not-an-int", + "1|2|3", + "+1|2", + "١|٢", + "1 |2", + "1|-2", + f"{1 << 63}|1", + f"{'9' * 5000}|1", + ], +) +def test_parse_history_cursor_rejects_nonempty_invalid_values(value: object) -> None: + with pytest.raises(HistoryCursorInvalidError): + parse_history_cursor(value) + + def test_canonical_page_parts_accepts_legacy_shapes() -> None: first = row(1) second = row(2) @@ -139,6 +164,47 @@ async def get_canonical_transcript_page(self, *args: Any, **kwargs: Any) -> obje assert manager.active_calls == ["agent:main:webchat:history"] +@pytest.mark.asyncio +async def test_adapter_preserves_cursor_invalidation() -> None: + class InvalidatedManager(CanonicalManager): + async def get_canonical_transcript_page(self, *args: Any, **kwargs: Any) -> object: + raise HistoryCursorInvalidatedError("anchor missing") + + manager = InvalidatedManager(None) + adapter = SessionHistoryStorageAdapter(manager) + with pytest.raises(HistoryCursorInvalidatedError): + await adapter.application().read_page( + SessionHistoryQuery( + session_key="agent:main:webchat:history", + limit=1, + before=(2, 2), + ) + ) + assert manager.active_calls == [] + + +@pytest.mark.asyncio +async def test_adapter_does_not_mislabel_a_canonical_read_failure() -> None: + class BrokenManager(CanonicalManager): + async def get_canonical_transcript_page(self, *args: Any, **kwargs: Any) -> object: + raise OSError("projection unavailable") + + async def get_transcript(self, session_key: str) -> list[SimpleNamespace]: + self.active_calls.append(session_key) + return [row(1)] + + manager = BrokenManager(None) + adapter = SessionHistoryStorageAdapter(manager) + with pytest.raises(CanonicalHistoryReadError): + await adapter.application().read_page( + SessionHistoryQuery( + session_key="agent:main:webchat:history", + limit=1, + before=(2, 2), + ) + ) + + @pytest.mark.asyncio async def test_adapter_preserves_storage_busy_error() -> None: class BusyManager(CanonicalManager): diff --git a/tests/test_session/test_manager.py b/tests/test_session/test_manager.py index 1e5d33108a..4234f39cea 100644 --- a/tests/test_session/test_manager.py +++ b/tests/test_session/test_manager.py @@ -14,6 +14,7 @@ import pytest import pytest_asyncio +from opensquilla.history_cursor import HistoryCursorInvalidatedError from opensquilla.session import manager as session_manager_module from opensquilla.session.attachment_manifest import ( ATTACHMENT_MANIFEST_STATE_KIND, @@ -4952,6 +4953,129 @@ def execute(sql: str, params: Any = ()): ] +@pytest.mark.asyncio +async def test_canonical_page_rejects_unknown_and_cross_session_cursors(manager): + first = await manager.create("agent:main:webchat:cursor-a") + second = await manager.create("agent:main:webchat:cursor-b") + await manager.append_message(first.session_key, "user", "first") + await manager.append_message(second.session_key, "user", "second") + anchor = (await manager.get_transcript(first.session_key))[0] + assert anchor.id is not None + + for cursor in ((anchor.created_at, anchor.id), (9_999_999, 9_999_999)): + with pytest.raises(HistoryCursorInvalidatedError): + await manager.get_canonical_transcript_page( + second.session_key, + limit=10, + before=cursor, + ) + + +@pytest.mark.asyncio +async def test_canonical_page_keeps_unaddressable_legacy_archive_rows(manager): + node = await manager.create("agent:main:webchat:legacy-cursor") + await manager._storage.conn.execute( + """ + INSERT INTO compacted_transcript_entries ( + session_id, session_key, original_entry_id, message_id, role, + content, created_at, archived_at, schema_version + ) VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?) + """, + ( + node.session_id, + node.session_key, + "legacy-message", + "user", + "legacy content", + 10, + 20, + 1, + ), + ) + await manager._storage.conn.commit() + + page = await manager.get_canonical_transcript_page(node.session_key, limit=10) + + assert [entry.content for entry in page.entries] == ["legacy content"] + assert page.entries[0].id is None + assert page.canonical_complete is False + + +@pytest.mark.asyncio +async def test_cursor_validation_and_page_share_one_sqlite_snapshot( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + db_path = tmp_path / "history-cursor-snapshot.db" + writer_storage = SessionStorage(str(db_path)) + await writer_storage.connect() + writer = SessionManager(writer_storage, inject_time_prefix=False) + node = await writer.create("agent:main:webchat:cursor-snapshot") + for index in range(3): + await writer_storage.append_transcript_entry( + TranscriptEntry( + session_id=node.session_id, + session_key=node.session_key, + message_id=f"snapshot-{index}", + role="user", + content=f"message {index}", + created_at=1_000 + index, + ) + ) + anchor = (await writer.get_transcript(node.session_key))[0] + assert anchor.id is not None + + reader_storage = SessionStorage(str(db_path)) + await reader_storage.connect() + original_execute = reader_storage.conn.execute + deletion_injected = False + + async def delete_after_snapshot() -> None: + nonlocal deletion_injected + if deletion_injected: + return + await writer_storage.delete_transcript(node.session_id) + deletion_injected = True + + class DeleteAfterFetch: + def __init__(self, delegate: Any) -> None: + self._delegate = delegate + self._cursor: Any = None + + async def __aenter__(self): + self._cursor = await self._delegate.__aenter__() + return self + + async def fetchall(self): + rows = await self._cursor.fetchall() + await delete_after_snapshot() + return rows + + async def __aexit__(self, *args: Any): + return await self._delegate.__aexit__(*args) + + def execute(sql: str, params: Any = ()): + result = original_execute(sql, params) + if "WITH cursor_anchor AS" in " ".join(sql.split()): + return DeleteAfterFetch(result) + return result + + monkeypatch.setattr(reader_storage.conn, "execute", execute) + try: + entries, has_more = await reader_storage.get_canonical_transcript_page( + node.session_id, + limit=10, + after=(anchor.created_at, anchor.id), + ) + finally: + await reader_storage.close() + await writer_storage.close() + + assert deletion_injected is True + assert has_more is False + assert [entry.message_id for entry in entries] == ["snapshot-1", "snapshot-2"] + + @pytest.mark.asyncio async def test_canonical_page_completeness_uses_post_page_compaction_snapshot( tmp_path: Path, From c0ee18d9c216e8e584e714581781e59ec7c83671 Mon Sep 17 00:00:00 2001 From: Open-Squilla <275096992+Open-Squilla@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:05:12 +0800 Subject: [PATCH 2/6] Fix background history refresh and cursor recovery results --- .../composables/chat/useChatHistory.test.ts | 123 ++++++++++++++++-- .../src/composables/chat/useChatHistory.ts | 9 +- .../chat/useChatSessionSubscription.test.ts | 60 ++++++++- 3 files changed, 176 insertions(+), 16 deletions(-) diff --git a/opensquilla-webui/src/composables/chat/useChatHistory.test.ts b/opensquilla-webui/src/composables/chat/useChatHistory.test.ts index a1cb9099ae..668ce52b1d 100644 --- a/opensquilla-webui/src/composables/chat/useChatHistory.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatHistory.test.ts @@ -2231,8 +2231,99 @@ describe('useChatHistory canonical pagination', () => { expect(readHistory).toHaveBeenCalledTimes(3) }) - it('replaces stale canonical rows by retrying a rejected cursor from latest', async () => { + 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((_resolve, reject) => { + rejectRecovery = reject + }) historyFixture .mockResolvedValueOnce({ messages: [historyMessage('m4')], @@ -2241,9 +2332,8 @@ describe('useChatHistory canonical pagination', () => { newestCursor: 'cursor-4', canonicalAvailable: true, }) - .mockRejectedValueOnce( - new SessionReadHistoryCursorError('stale', 'cursor rejected'), - ) + .mockRejectedValueOnce(new SessionReadHistoryCursorError('stale', 'cursor rejected')) + .mockReturnValueOnce(recovery) .mockResolvedValueOnce({ messages: [historyMessage('m9')], hasMore: false, @@ -2254,15 +2344,23 @@ describe('useChatHistory canonical pagination', () => { await api.loadHistory() await api.loadEarlierHistory() - await api.retryHistory() + const retry = api.retryHistory() + const joined = api.loadHistory() + const reconciliation = api.reconcileHistory() + expect(joined).toBe(retry) + rejectRecovery(recoveryError) - expect(readHistory).toHaveBeenNthCalledWith( - 2, - 'before', - 'cursor-4', - expect.any(Object), - ) - expect(readHistory).toHaveBeenNthCalledWith(3, 'latest', null, expect.any(Object)) + 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', @@ -2270,6 +2368,7 @@ describe('useChatHistory canonical pagination', () => { loadEarlierError: false, recoveryError: false, }) + api.cleanup() }) it('surfaces and retries an initial history request failure', async () => { diff --git a/opensquilla-webui/src/composables/chat/useChatHistory.ts b/opensquilla-webui/src/composables/chat/useChatHistory.ts index 8b1dbf69ab..2a6923c3dc 100644 --- a/opensquilla-webui/src/composables/chat/useChatHistory.ts +++ b/opensquilla-webui/src/composables/chat/useChatHistory.ts @@ -724,6 +724,7 @@ type FailedHistoryRequest = | { kind: 'latest' key: string + error: unknown } const MAX_FORWARD_BRIDGE_PAGES = 2 @@ -794,7 +795,7 @@ export function useChatHistory(options: UseChatHistoryOptions) { historySyncTimer = null const timerNonReconnecting = historySyncTimerNonReconnecting historySyncTimerNonReconnecting = false - if (historyState.value.loading || failedHistoryRequest) { + if (historyState.value.loading || failedHistoryRequest?.kind === 'latest') { historySyncPending = true historySyncPendingNonReconnecting ||= timerNonReconnecting return @@ -1459,7 +1460,7 @@ export function useChatHistory(options: UseChatHistoryOptions) { } const initialLoadFailed = isInitialLoad && !bridgeAttempted failedHistoryRequest = cursorRequiresLatestReload || params.replaceCanonicalWindow - ? { kind: 'latest', key } + ? { kind: 'latest', key, error } : bridgeAttempted ? { kind: 'bridge', key } : { @@ -1504,7 +1505,9 @@ export function useChatHistory(options: UseChatHistoryOptions) { ) { historySyncPending = true historySyncPendingNonReconnecting ||= Boolean(params.nonReconnecting) - return + // Bootstrap and live reconciliation treat an absent result as success. + // Keep their recovery fence closed until the replacement page succeeds. + return Promise.resolve({ ok: false, error: failedHistoryRequest.error }) } if (activeHistory) { if ( diff --git a/opensquilla-webui/src/composables/chat/useChatSessionSubscription.test.ts b/opensquilla-webui/src/composables/chat/useChatSessionSubscription.test.ts index 1456868a00..f401b41d00 100644 --- a/opensquilla-webui/src/composables/chat/useChatSessionSubscription.test.ts +++ b/opensquilla-webui/src/composables/chat/useChatSessionSubscription.test.ts @@ -6,9 +6,11 @@ import { type UseChatSessionSubscriptionOptions, } from './useChatSessionSubscription' import { useChatTaskOwnership, type ChatTaskOwnershipApi } from './useChatTaskOwnership' +import { useChatHistory } from './useChatHistory' import { createConversationRuntime } from '@/modules/conversationRuntime' import { createSessionReadLifecycle, + SessionReadHistoryCursorError, SessionReadSessionMissingError, type SessionReadHistoryPage, type SessionReadLease, @@ -101,6 +103,7 @@ const EMPTY_HISTORY: SessionReadHistoryPage = { function leaseFixture(options: { live?: SessionReadLive | Promise + history?: SessionReadLease['history'] metadata?: SessionReadMetadata | Promise retryMetadata?: () => Promise criticalRequestsQueued?: Promise @@ -114,7 +117,7 @@ function leaseFixture(options: { criticalRequestsQueued: options.criticalRequestsQueued ?? Promise.resolve(), live: Promise.resolve(options.live ?? live()), metadata: metadataPromise, - history: { + history: options.history ?? { latest: async () => EMPTY_HISTORY, before: async () => EMPTY_HISTORY, after: async () => EMPTY_HISTORY, @@ -246,6 +249,61 @@ function harness( } describe('useChatSessionSubscription domain lease', () => { + it('waits for a rejected history cursor to recover before confirming reconciliation', async () => { + const confirmInstalled = vi.fn(async () => {}) + const page: SessionReadHistoryPage = { + ...EMPTY_HISTORY, + messages: [{ + id: 'm4', messageId: 'm4', transcriptId: 'transcript:m4', + role: 'assistant', text: 'hello', createdAt: 1, + reasoningContent: null, routerDecision: null, + artifacts: [], toolCalls: [], timeline: [], attachments: [], promptAnnotations: [], + provenance: { kind: null, sourceSessionKey: null, sourceTool: null }, + turnContext: null, usage: null, model: null, inputTokens: null, outputTokens: null, + additional: {}, + }], + hasMore: true, + oldestCursor: 'cursor-4', + newestCursor: 'cursor-4', + } + const latest = vi.fn(async () => page) + const before = vi.fn(async () => { + throw new SessionReadHistoryCursorError('stale', 'cursor rejected') + }) + const fixture = leaseFixture({ + live: live({ confirmInstalled }), + history: { latest, before, after: async () => EMPTY_HISTORY }, + }) + const history = useChatHistory({ + sessionReadLeaseReader: { current: () => fixture.lease }, + sessionKey: ref(KEY), + messages: ref([]), + lastHeaderRole: ref(''), + lastHeaderDay: ref(''), + stripTimePrefix: text => text, + scrollToBottom: vi.fn(), + }) + const subject = harness(fixture.lease, { loadHistory: () => history.reconcileHistory() }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await history.loadHistory() + await history.loadEarlierHistory() + + await expect(subject.api.reconcileSession()).resolves.toMatchObject({ authoritative: false }) + expect(latest).toHaveBeenCalledTimes(1) + expect(confirmInstalled).not.toHaveBeenCalled() + + await history.retryHistory() + await expect(subject.api.reconcileSession()).resolves.toMatchObject({ authoritative: true }) + expect(confirmInstalled).toHaveBeenCalledTimes(1) + expect(before).toHaveBeenCalledTimes(1) + expect(fixture.close).not.toHaveBeenCalled() + } finally { + history.cleanup() + warn.mockRestore() + } + }) + it('does not declare installation when history reconciliation returns an explicit failed result', async () => { const confirmInstalled = vi.fn(async () => {}) const fixture = leaseFixture({ live: live({ confirmInstalled }) }) From 69a7c051db7144af211dbf299bbcccecc4515ef5 Mon Sep 17 00:00:00 2001 From: Open-Squilla <275096992+Open-Squilla@users.noreply.github.com> Date: Thu, 17 Sep 2026 03:54:33 +0800 Subject: [PATCH 3/6] test(goals): capture expected observer failure logs --- tests/test_gateway/test_goal_rpc.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/tests/test_gateway/test_goal_rpc.py b/tests/test_gateway/test_goal_rpc.py index 695fc46332..0870648a74 100644 --- a/tests/test_gateway/test_goal_rpc.py +++ b/tests/test_gateway/test_goal_rpc.py @@ -3930,6 +3930,8 @@ async def fail_settlement(*_args: Any, **_kwargs: Any) -> Any: async def test_goal_event_observer_failure_never_changes_durable_tool_result( tmp_path: Path, ) -> None: + from structlog.testing import capture_logs + service: GoalService | None = None async def handler(run: TaskRun) -> None: @@ -3960,19 +3962,24 @@ async def fail_emit(*_args: Any, **_kwargs: Any) -> None: raise OSError("synthetic event observer failure") stack.service._event_emitter = fail_emit - created = await _handle_goals_set(_set_params(), stack.context) - # The synthetic observer raises on every lifecycle projection. Under a - # loaded suite, rendering those expected warning tracebacks can take - # longer than the normal in-memory task path without changing the - # durability contract under test. - task = await stack.runtime.wait(created["taskId"], timeout=5.0) - complete = await _wait_for_goal( - stack.storage, - lambda goal: goal.status == "complete" and goal.active_task_id is None, - ) + # Capture expected observer failures without rendering their tracebacks + # on the event loop while the bounded durability check is running. + with capture_logs() as logs: + created = await _handle_goals_set(_set_params(), stack.context) + task = await stack.runtime.wait(created["taskId"], timeout=5.0) + complete = await _wait_for_goal( + stack.storage, + lambda goal: goal.status == "complete" and goal.active_task_id is None, + ) assert task.status == AgentTaskStatus.SUCCEEDED assert complete.progress_revision == 1 assert complete.terminal_reason == "model_complete" + failures = [ + event for event in logs if event["event"] == "goal.event_emit_failed" + ] + assert {event["event_type"] for event in failures} == {"created", "updated"} + assert all(event["log_level"] == "warning" for event in failures) + assert all(event["exc_info"] is True for event in failures) @pytest.mark.asyncio From 56b4314650958013047ec9f46483029a1f8638ab Mon Sep 17 00:00:00 2001 From: Open-Squilla <275096992+Open-Squilla@users.noreply.github.com> Date: Thu, 17 Sep 2026 04:24:28 +0800 Subject: [PATCH 4/6] fix(windows): preserve native private path bind errors --- src/opensquilla/private_paths.py | 5 +++- tests/test_private_paths.py | 47 ++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/opensquilla/private_paths.py b/src/opensquilla/private_paths.py index a6df35b419..ae7d217390 100644 --- a/src/opensquilla/private_paths.py +++ b/src/opensquilla/private_paths.py @@ -344,7 +344,10 @@ def open_bound( invalid_handle = ctypes.c_void_p(-1).value if handle in {None, invalid_handle}: error_number = _windows_last_error() - raise OSError(error_number, "cannot bind private Windows path") + # Keep the Win32 code so existing bounded sharing retries can + # distinguish access/sharing failures from permanent errors. + win_error = getattr(ctypes, "WinError") + raise win_error(error_number, "cannot bind private Windows path") try: attributes = _WindowsFileAttributeTagInfo() if not get_information( diff --git a/tests/test_private_paths.py b/tests/test_private_paths.py index 65e72fc807..a0eff22c9b 100644 --- a/tests/test_private_paths.py +++ b/tests/test_private_paths.py @@ -1,14 +1,61 @@ from __future__ import annotations import contextlib +import ctypes +import os from collections.abc import Iterator from pathlib import Path +from types import SimpleNamespace import pytest from opensquilla import private_paths +@pytest.mark.skipif(os.name != "nt", reason="Windows error translation is native") +@pytest.mark.parametrize( + ("error_number", "retryable"), + [(5, True), (32, True), (33, True), (87, False)], +) +def test_windows_private_path_bind_preserves_native_error_for_owner_registry( + error_number: int, + retryable: bool, +) -> None: + from opensquilla import process_tree + + def fail_open(*_args: object) -> int | None: + ctypes.set_last_error(error_number) + return ctypes.c_void_p(-1).value + + def unexpected_handle_operation(*_args: object) -> None: + pytest.fail("an invalid private path handle must not be inspected or closed") + + api = private_paths._CtypesWindowsPrivateAcl.__new__( + private_paths._CtypesWindowsPrivateAcl + ) + api.kernel32 = SimpleNamespace( + CreateFileW=fail_open, + GetFileInformationByHandleEx=unexpected_handle_operation, + CloseHandle=unexpected_handle_operation, + ) + + with pytest.raises(OSError, match="cannot bind private Windows path") as exc_info: + with api.open_bound( + Path("synthetic-private-file"), + directory=False, + expected_device=7, + expected_inode=42, + ): + pytest.fail("a failed private path bind must not yield a handle") + + assert exc_info.value.winerror == error_number + assert isinstance(exc_info.value, PermissionError) is retryable + assert ( + process_tree._is_transient_owner_registry_write_error(exc_info.value) + is retryable + ) + + def test_windows_private_acl_is_verified_through_the_same_bound_handle() -> None: events: list[tuple[object, ...]] = [] From 51093b2c450f1ced28618aa3cb0e56bc0e63ec5c Mon Sep 17 00:00:00 2001 From: Open-Squilla <275096992+Open-Squilla@users.noreply.github.com> Date: Thu, 17 Sep 2026 05:26:59 +0800 Subject: [PATCH 5/6] test(shell): use current interpreter for native execution --- .../test_tools/test_workspace_write_deny_levers.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/test_tools/test_workspace_write_deny_levers.py b/tests/test_tools/test_workspace_write_deny_levers.py index d6e79b11ea..4cadee221e 100644 --- a/tests/test_tools/test_workspace_write_deny_levers.py +++ b/tests/test_tools/test_workspace_write_deny_levers.py @@ -14,6 +14,8 @@ import json import os +import shlex +import sys from pathlib import Path import pytest @@ -63,6 +65,13 @@ def _configure_ctx(workspace: Path, globs: list[str]) -> ToolContext: return ctx +def _python_shell_command(script: str) -> str: + argv = [sys.executable, "-c", script] + if os.name == "nt": + return "& " + " ".join("'" + arg.replace("'", "''") + "'" for arg in argv) + return shlex.join(argv) + + @pytest.mark.parametrize( ("command", "expected"), [ @@ -331,7 +340,7 @@ async def test_exec_command_interpreter_write_passes_through_by_default( _configure_ctx(workspace, ["tests/**"]) result = await shell.exec_command( - "python3 -c \"open('tests/test_a.py','w').write('assert b')\"", + _python_shell_command("open('tests/test_a.py','w').write('assert b')"), workdir=str(workspace), ) @@ -351,7 +360,7 @@ async def test_exec_command_interpreter_reads_stay_unblocked_with_lever_on( _configure_ctx(workspace, ["tests/**"]) result = await shell.exec_command( - "python3 -c \"print(open('tests/test_a.py').read())\"", + _python_shell_command("print(open('tests/test_a.py').read())"), workdir=str(workspace), ) From 656ca7561d830f3c6ca5daec20ca4dc4b9606672 Mon Sep 17 00:00:00 2001 From: Open-Squilla <275096992+Open-Squilla@users.noreply.github.com> Date: Thu, 17 Sep 2026 06:22:00 +0800 Subject: [PATCH 6/6] test(windows): isolate bounded startup and cancellation contracts --- tests/test_gateway/test_turn_ingress_rpc.py | 3 +++ .../test_runtime_pack_manager.py | 16 +++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/tests/test_gateway/test_turn_ingress_rpc.py b/tests/test_gateway/test_turn_ingress_rpc.py index b61ba32ae7..8b54778219 100644 --- a/tests/test_gateway/test_turn_ingress_rpc.py +++ b/tests/test_gateway/test_turn_ingress_rpc.py @@ -2779,6 +2779,9 @@ async def test_sessions_send_fast_replay_consumes_legacy_meta_launch_draft( assert await stack.storage.list_meta_launch_drafts(session_key=SESSION_KEY) == [] +# Keep the SQLite-backed startup prerequisite within its scheduling budget; +# the contract below checks replay state, not replay latency under runner load. +@pytest.mark.ci_serial @pytest.mark.asyncio async def test_sessions_send_replay_exposes_terminal_task_status(tmp_path: Path) -> None: async with _open_real_stack(tmp_path / "sessions.db") as stack: diff --git a/tests/test_runtime_packs/test_runtime_pack_manager.py b/tests/test_runtime_packs/test_runtime_pack_manager.py index 3cdc797f7c..40dfd1449b 100644 --- a/tests/test_runtime_packs/test_runtime_pack_manager.py +++ b/tests/test_runtime_packs/test_runtime_pack_manager.py @@ -1421,6 +1421,9 @@ def test_cancel_does_not_promise_cancellation_after_activation_boundary( assert not event.is_set() +# Real archive extraction must reach the bounded probe handshake without +# competing with the CI worker pool's disk writes. +@pytest.mark.ci_serial def test_cancel_during_probe_keeps_old_activation_and_resumable_download( tmp_path: Path, monkeypatch: Any, @@ -1456,11 +1459,14 @@ def blocking_probe(*_args: object) -> None: monkeypatch.setattr(runtime_pack_manager, "_run_probe", blocking_probe) operation = service.start_install("python") - assert entered.wait(5) - cancelling = service.cancel("python", operation.operation_id) - assert cancelling.state is RuntimeOperationState.CANCELLING - release.set() - completed = service.wait_for_operation(operation.operation_id) + try: + assert entered.wait(5) + cancelling = service.cancel("python", operation.operation_id) + assert cancelling.state is RuntimeOperationState.CANCELLING + finally: + release.set() + completed = service.wait_for_operation(operation.operation_id) + assert operation.operation_id not in service._threads assert completed is not None and completed.state is RuntimeOperationState.CANCELLED active = service.active_runtime("python")