diff --git a/.github/scripts/windows_test_assignments.json b/.github/scripts/windows_test_assignments.json index ad64ac38f..266a77974 100644 --- a/.github/scripts/windows_test_assignments.json +++ b/.github/scripts/windows_test_assignments.json @@ -126,6 +126,7 @@ "tests/test_engine/test_usage_event_accounting.py", "tests/test_engine/test_usage_tracker.py", "tests/test_engine/turn_runner/test_canonical_text_contract.py", + "tests/test_engine/turn_runner/test_cron_provenance.py", "tests/test_engine/turn_runner/test_provider_and_tools_stage_snapshot.py", "tests/test_engine/turn_runner/test_router_control_replay_event.py", "tests/test_engine/turn_runner/test_stream_consumer_stage_snapshot.py", diff --git a/.github/scripts/windows_test_durations.json b/.github/scripts/windows_test_durations.json index 4a40c9f53..4ee1cb474 100644 --- a/.github/scripts/windows_test_durations.json +++ b/.github/scripts/windows_test_durations.json @@ -474,6 +474,7 @@ "tests/test_engine/turn_runner/test_canonical_text_contract.py": 0.05, "tests/test_engine/turn_runner/test_compaction_and_history_stage_snapshot.py": 1.233, "tests/test_engine/turn_runner/test_compaction_and_history_stage_unit.py": 0.05, + "tests/test_engine/turn_runner/test_cron_provenance.py": 0.01, "tests/test_engine/turn_runner/test_harness_agent_factory_adapter.py": 0.02, "tests/test_engine/turn_runner/test_input_normalization_metadata.py": 0.023, "tests/test_engine/turn_runner/test_input_stage_snapshot.py": 0.509, diff --git a/contracts/gateway/v4/compatibility-manifest.generated.json b/contracts/gateway/v4/compatibility-manifest.generated.json index 8f1ef40b7..ec6b0b036 100644 --- a/contracts/gateway/v4/compatibility-manifest.generated.json +++ b/contracts/gateway/v4/compatibility-manifest.generated.json @@ -373,7 +373,7 @@ "lifecycle": "stable", "name": "chat.send", "schema": "conversation/chat-send.schema.json", - "schemaSha256": "85a8137aeff9a9c5f6ac3491dfadbfdf8050935f019d4bdab0cee78ce13c48d9" + "schemaSha256": "ee0da1e5d87a98bcd4c73cffc666b71f76f9d0500fb50f245ad251f549a47a17" }, { "lifecycle": "stable", @@ -1295,7 +1295,7 @@ "lifecycle": "stable", "name": "sessions.send", "schema": "conversation/sessions-send.schema.json", - "schemaSha256": "7a1fb4de4d1a5ea5714703bcf21d15ccec633d8b8f3a4ea55319e10a04d7435e" + "schemaSha256": "3382c9945ecbccda159ee5389b93af498ffdad1aa5716aedf961feb553b82ce7" }, { "canonicalName": "sessions.steer.v2", @@ -1464,7 +1464,7 @@ "generatorSha256": "a4d9386a5dc58a59682c72a551d23aac995b95619a0d504abec20ef047671e00", "methodCount": 215, "schemaCount": 224, - "schemaTreeSha256": "4fe17977e4b9ba7c15531c8232d68e8f47c3927ca4e9d631b5efc1a2172b54fb" + "schemaTreeSha256": "4cad213cb5943a1f97618e7e6e6526761afe9e4a69db83ae2d5519309202be18" }, "wireVersion": 4 } diff --git a/contracts/gateway/v4/conversation/chat-send.schema.json b/contracts/gateway/v4/conversation/chat-send.schema.json index ef4442945..7630510cb 100644 --- a/contracts/gateway/v4/conversation/chat-send.schema.json +++ b/contracts/gateway/v4/conversation/chat-send.schema.json @@ -40,7 +40,6 @@ { "code": "QUEUE_FULL", "retryable": true }, { "code": "QUEUE_FULL_DIRTY", "retryable": false, "details": true }, { "code": "SESSION_CHANGED", "retryable": true }, - { "code": "SESSION_NOT_INTERACTIVE", "retryable": false }, { "code": "IDEMPOTENCY_CONFLICT", "retryable": false }, { "code": "META_CONTROL_CONFLICT", "retryable": false }, { "code": "COLLECT_RACE", "retryable": true }, diff --git a/contracts/gateway/v4/conversation/sessions-send.schema.json b/contracts/gateway/v4/conversation/sessions-send.schema.json index 41b22a5cc..451a685cb 100644 --- a/contracts/gateway/v4/conversation/sessions-send.schema.json +++ b/contracts/gateway/v4/conversation/sessions-send.schema.json @@ -34,7 +34,6 @@ { "code": "QUEUE_FULL", "retryable": true }, { "code": "QUEUE_FULL_DIRTY", "details": true }, { "code": "SESSION_CHANGED", "retryable": true }, - { "code": "SESSION_NOT_INTERACTIVE", "retryable": false }, { "code": "IDEMPOTENCY_CONFLICT" }, { "code": "INTERNAL_ERROR" } ] }, diff --git a/opensquilla-webui/e2e/history-hydration.spec.ts b/opensquilla-webui/e2e/history-hydration.spec.ts index 403ec504d..74096cb73 100644 --- a/opensquilla-webui/e2e/history-hydration.spec.ts +++ b/opensquilla-webui/e2e/history-hydration.spec.ts @@ -1117,3 +1117,161 @@ test('ignores a late history response after navigating to another session', asyn await expect(page.getByText('Late history from session A must stay hidden.')).toHaveCount(0) await expect.poll(() => new URL(page.url()).searchParams.get('session')).toBe(SESSION_B) }) + +test.describe('Automation conversation continuation', () => { + const SESSION_KEY = 'cron:inventory:run:first' + const ORIGINAL_RESULT = 'The scheduled inventory check counted 12 items.' + const FOLLOWUP = 'Explain the inventory count' + const REPLY = 'The count includes 8 stored items and 4 displayed items.' + + async function installAutomationConversation( + page: Page, + sessionKey = SESSION_KEY, + scheduledPrompt?: string, + ) { + const requests: Array<{ method: string, params: Record }> = [] + const history: Array> = [{ + role: 'assistant', + text: ORIGINAL_RESULT, + message_id: 'automation-result', + timestamp: '2026-01-01T09:00:00Z', + provenance_kind: 'cron', + provenance_source_tool: 'cron:inventory', + }] + if (scheduledPrompt) { + history.unshift({ + role: 'user', text: scheduledPrompt, message_id: 'automation-prompt', + timestamp: '2026-01-01T08:59:59Z', + provenance_kind: 'cron', provenance_source_tool: 'cron:inventory', + provenance_source_session_key: sessionKey, + }) + } + await page.addInitScript(() => localStorage.setItem('opensquilla-locale', 'en')) + await page.route('**/api/system/update', route => route.fulfill({ json: {} })) + await page.route('**/api/elevated-mode', route => route.fulfill({ json: { enabled: false } })) + await page.route('**/api/approvals', route => route.fulfill({ + json: { pending: [], mode: 'prompt', allowPatterns: [], denyPatterns: [] }, + })) + await page.routeWebSocket(/\/ws$/, ws => { + const respond = (id: unknown, payload: unknown) => ws.send(JSON.stringify({ + type: 'res', id, ok: true, payload, + })) + ws.send(JSON.stringify({ type: 'event', event: 'connect.challenge', payload: {} })) + ws.onMessage(raw => { + const frame = JSON.parse(String(raw)) + if (frame.type === 'ping') { + ws.send(JSON.stringify({ type: 'pong' })) + return + } + if (frame.type !== 'req') return + const method = String(frame.method) + const params = frame.params || {} + requests.push({ method, params }) + if (method === 'connect') { + ws.send(JSON.stringify({ + protocol: 3, + policy: { tick_interval_ms: 30_000 }, + auth: { principal: { isOwner: true } }, + })) + return + } + if (method === 'chat.history') { + respond(frame.id, chatHistoryPayload(history)) + return + } + if (method === 'chat.send') { + history.push({ + role: 'user', text: params.message, message_id: 'followup-user', + timestamp: '2026-01-01T09:01:00Z', + turn_context: { turn_id: 'followup-turn' }, + }, { + role: 'assistant', text: REPLY, message_id: 'followup-assistant', + timestamp: '2026-01-01T09:01:01Z', + turn_context: { turn_id: 'followup-turn' }, + }) + respond(frame.id, { + sessionKey, status: 'accepted', accepted: true, + task_id: 'followup-turn', turn_id: 'followup-turn', message_id: 'followup-user', + }) + return + } + const payloads: Record = { + 'agents.list': { agents: [] }, + 'commands.list_for_surface': { commands: [] }, + 'config.get': { squilla_router: { enabled: false }, permissions: {}, skills: {} }, + 'models.routing.get': { mode: 'direct' }, + 'onboarding.status': { audioConfigured: false }, + 'sandbox.run_mode.preference.get': { runMode: 'full', source: 'config' }, + 'sessions.resolve': { session_key: sessionKey, session_id: 'automation-session' }, + 'sessions.list': { + sessions: [{ + key: sessionKey, title: 'Inventory automation', + sessionKind: sessionKey.startsWith('cron:') ? 'cron' : 'chat', + surface: sessionKey.startsWith('cron:') ? 'cron' : 'webchat', + interactive: true, conversationKind: 'direct', effectiveAgentId: 'main', + updatedAt: 100, messageCount: history.length, status: 'ok', runStatus: 'idle', + }], + has_more: false, + }, + 'sessions.messages.subscribe': sessionMessagesSubscribePayload(sessionKey), + 'sessions.messages.hydrate': sessionMessagesHydratePayload(sessionKey), + 'sessions.messages.snapshot': sessionMessagesSnapshotPayload(sessionKey), + 'sessions.messages.unsubscribe': { subscribed: false }, + 'sessions.subscribe': { subscribed: true }, + 'usage.status': { sessions: [] }, + } + respond(frame.id, payloads[method] ?? {}) + }) + }) + return requests + } + + for (const sessionKey of [SESSION_KEY, 'agent:main:webchat:inventory']) { + test(`scheduled sources survive reload and exclude manual follow-ups in ${sessionKey}`, async ({ page }) => { + const prompt = 'Count the stored and displayed inventory items.' + const requests = await installAutomationConversation(page, sessionKey, prompt) + await page.goto('/control/chat?session=' + encodeURIComponent(sessionKey)) + await expect(page.getByTestId('cron-input-source')).toHaveText('Scheduled trigger') + await expect(page.locator('.msg-user-bubble').filter({ hasText: prompt })).toHaveCount(1) + await expect(page.locator('.msg-provenance-chip')).toHaveText('Scheduled') + + const composer = page.getByRole('textbox', { name: 'Message to send' }) + await composer.fill(FOLLOWUP) + await composer.press('Enter') + await expect.poll(() => requests.filter(request => request.method === 'chat.send').length).toBe(1) + expect(requests.find(request => request.method === 'chat.send')!.params.sessionKey).toBe(sessionKey) + + await page.reload() + await expect(page.locator('.msg-user-bubble')).toHaveCount(2) + await expect(page.locator('.msg-ai').filter({ hasText: REPLY })).toHaveCount(1) + await expect(page.getByTestId('cron-input-source')).toHaveCount(1) + await expect(page.getByTestId('cron-input-source')).toHaveText('Scheduled trigger') + await expect(page.locator('.msg-provenance-chip')).toHaveCount(1) + await expect(composer).toBeEditable() + }) + } + + test('automation run supports a follow-up in the original conversation after reload', async ({ page }) => { + const requests = await installAutomationConversation(page) + const route = '/control/chat?session=' + encodeURIComponent(SESSION_KEY) + await page.goto(route) + await expect(page.locator('.msg-ai')).toContainText(ORIGINAL_RESULT) + const composer = page.getByRole('textbox', { name: 'Message to send' }) + await expect(composer).toBeEditable() + await composer.fill(FOLLOWUP) + await composer.press('Enter') + + await expect.poll(() => requests.filter(request => request.method === 'chat.send').length).toBe(1) + const send = requests.find(request => request.method === 'chat.send')! + expect(send.params.sessionKey).toBe(SESSION_KEY) + expect(send.params.message).toBe(FOLLOWUP) + expect(requests.filter(request => ['sessions.create', 'sessions.fork'].includes(request.method))).toEqual([]) + + await page.reload() + await expect(page.locator('.msg-ai').filter({ hasText: ORIGINAL_RESULT })).toHaveCount(1) + await expect(page.locator('.msg-user-bubble')).toContainText(FOLLOWUP) + await expect(page.locator('.msg-ai').filter({ hasText: REPLY })).toHaveCount(1) + await expect(composer).toBeEditable() + await expect(page).toHaveURL(new RegExp(encodeURIComponent(SESSION_KEY))) + }) +}) diff --git a/opensquilla-webui/src/adapters/gateway/sessionDirectoryV4.test.ts b/opensquilla-webui/src/adapters/gateway/sessionDirectoryV4.test.ts index f5b359098..d0bd44aa0 100644 --- a/opensquilla-webui/src/adapters/gateway/sessionDirectoryV4.test.ts +++ b/opensquilla-webui/src/adapters/gateway/sessionDirectoryV4.test.ts @@ -9,10 +9,7 @@ import { import { SESSIONS_LIST_METHOD } from '@/contracts/generated/v4/sessionsList' import { SESSIONS_RESOLVE_METHOD } from '@/contracts/generated/v4/sessionsResolve' import { SESSIONS_SEARCH_METHOD } from '@/contracts/generated/v4/sessionsSearch' -import { - isCronSessionKey, - type SessionDirectory, -} from '@/modules/sessionDirectory' +import type { SessionDirectory } from '@/modules/sessionDirectory' import { useSessions } from '@/composables/useSessions' type SessionDirectoryTransport = Parameters[0] @@ -47,13 +44,6 @@ function fixtureCase(document: string, caseId: string): WireFixture { } describe('v4 SessionDirectory Adapter', () => { - it('recognizes scheduler-owned cron session keys as read-only', () => { - expect(isCronSessionKey('cron:job-1:run:1')).toBe(true) - expect(isCronSessionKey(' CRON:job-1 ')).toBe(false) - expect(isCronSessionKey('cron:')).toBe(false) - expect(isCronSessionKey('agent:main:webchat:one')).toBe(false) - }) - it('uses the pinned legacy Gateway wire without requiring a new envelope', async () => { const request = fixtureCase('requests.json', 'request.page-first') const response = fixtureCase('responses.json', 'response.empty-page') diff --git a/opensquilla-webui/src/components/chat/UserMessage.cron-provenance.test.ts b/opensquilla-webui/src/components/chat/UserMessage.cron-provenance.test.ts new file mode 100644 index 000000000..72c79f7f6 --- /dev/null +++ b/opensquilla-webui/src/components/chat/UserMessage.cron-provenance.test.ts @@ -0,0 +1,50 @@ +// @vitest-environment happy-dom + +import { afterEach, describe, expect, it } from 'vitest' +import { createApp, nextTick } from 'vue' +import i18n, { loadLocaleMessages } from '@/i18n' +import type { ChatRenderedMessage } from '@/types/chat' +import UserMessage from './UserMessage.vue' + +afterEach(() => { + document.body.innerHTML = '' + i18n.global.locale.value = 'en' +}) + +describe('scheduled input source', () => { + it.each(['en', 'zh-Hans'] as const)('labels automatic prompts in %s', async (locale) => { + await loadLocaleMessages(locale) + i18n.global.locale.value = locale + const host = document.createElement('div') + document.body.appendChild(host) + const message: ChatRenderedMessage = { + id: 'scheduled-input', + role: 'user', + displayRole: 'user', + roleLabel: 'You', + text: 'Count the synthetic inventory.', + timeStr: '', + showHeader: false, + provenanceKind: 'cron', + } + const app = createApp(UserMessage, { + message, + shareMode: false, + shareSelected: false, + shareMessageId: message.id, + stripTimePrefix: (text: string) => text, + copyMessage: async () => true, + downloadAttachment: async () => true, + }) + app.use(i18n) + app.mount(host) + try { + await nextTick() + expect(host.querySelector('[data-testid="cron-input-source"]')?.textContent) + .toContain(locale === 'en' ? 'Scheduled trigger' : '定时触发') + expect(host.textContent).toContain(message.text) + } finally { + app.unmount() + } + }) +}) diff --git a/opensquilla-webui/src/components/chat/UserMessage.vue b/opensquilla-webui/src/components/chat/UserMessage.vue index 982feab57..8752892c9 100644 --- a/opensquilla-webui/src/components/chat/UserMessage.vue +++ b/opensquilla-webui/src/components/chat/UserMessage.vue @@ -27,6 +27,14 @@ above the text bubble, never packed inside it — text gets a filled bubble, images render as bordered bare media, files as icon chips. -->
+ +
{ + it('keeps persisted sources and labels live cron completions before history arrives', () => { + const api = renderedMessagesFor([ + { role: 'user', text: 'Run the inventory check.', ts: 1, provenanceKind: 'cron' }, + { role: 'assistant', text: '12 items.', ts: 2, turnRunKind: 'cron_turn' }, + { role: 'user', text: 'Scheduled trigger is a useful label.', ts: 3 }, + { role: 'assistant', text: 'A normal follow-up.', ts: 4, turnRunKind: 'session_turn' }, + { role: 'assistant', text: 'Persisted result.', ts: 5, provenanceKind: 'cron' }, + ]) + + expect(api.renderedMessages.value.map(message => message.provenanceKind)) + .toEqual(['cron', 'cron', undefined, undefined, 'cron']) + }) +}) + describe('useChatRenderedMessages annotation-only user turns', () => { it('keeps the live optimistic row when prompt annotations are the only visible payload', () => { const api = renderedMessagesFor([{ diff --git a/opensquilla-webui/src/composables/chat/useChatRenderedMessages.ts b/opensquilla-webui/src/composables/chat/useChatRenderedMessages.ts index 99de163d2..2c3f4c19f 100644 --- a/opensquilla-webui/src/composables/chat/useChatRenderedMessages.ts +++ b/opensquilla-webui/src/composables/chat/useChatRenderedMessages.ts @@ -574,7 +574,7 @@ export function useChatRenderedMessages(options: UseChatRenderedMessagesOptions) ? msg.activitySnapshotIncomplete : undefined, interrupted: msg.interrupted, - provenanceKind: msg.provenanceKind, + provenanceKind: msg.provenanceKind || (msg.turnRunKind === 'cron_turn' ? 'cron' : undefined), provenanceSourceSessionKey: msg.provenanceSourceSessionKey, provenanceSourceTool: msg.provenanceSourceTool, stopNotice: msg.stopNotice, diff --git a/opensquilla-webui/src/contracts/generated/v4/chatSend.ts b/opensquilla-webui/src/contracts/generated/v4/chatSend.ts index ab795e25b..b4c1eafc6 100644 --- a/opensquilla-webui/src/contracts/generated/v4/chatSend.ts +++ b/opensquilla-webui/src/contracts/generated/v4/chatSend.ts @@ -1,5 +1,5 @@ // @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -// source-sha256: ceb869f8ae1bab0b8d917de231dfe81a4c01923c1782a9858d84f5f28ecfae7a +// source-sha256: eeba035e6c29d2269ca3d9fa332b1805b2417de7497148a5bbff4525f36a34ed // generator-sha256: a4d9386a5dc58a59682c72a551d23aac995b95619a0d504abec20ef047671e00 /** @@ -170,4 +170,4 @@ export const CHAT_SEND_SCOPE = "operator.write" as const export const CHAT_SEND_IDEMPOTENCY = "idempotent" as const export const CHAT_SEND_TIMEOUT = {"policy":"caller"} as const export const CHAT_SEND_CAPABILITY = {"kind":"method-availability","name":"chat.send"} as const -export const CHAT_SEND_ERRORS = [{"code":"INVALID_PARAMS"},{"code":"INVALID_REQUEST"},{"code":"UNAUTHORIZED"},{"code":"UNAVAILABLE","retryable":true},{"code":"STORAGE_BUSY","details":true,"retryAfterMs":true,"retryable":true},{"code":"QUEUE_FULL","retryable":true},{"code":"QUEUE_FULL_DIRTY","details":true,"retryable":false},{"code":"SESSION_CHANGED","retryable":true},{"code":"SESSION_NOT_INTERACTIVE","retryable":false},{"code":"IDEMPOTENCY_CONFLICT","retryable":false},{"code":"META_CONTROL_CONFLICT","retryable":false},{"code":"COLLECT_RACE","retryable":true},{"code":"SESSION_CONFLICT","retryable":true},{"code":"INTERNAL_ERROR"}] as const +export const CHAT_SEND_ERRORS = [{"code":"INVALID_PARAMS"},{"code":"INVALID_REQUEST"},{"code":"UNAUTHORIZED"},{"code":"UNAVAILABLE","retryable":true},{"code":"STORAGE_BUSY","details":true,"retryAfterMs":true,"retryable":true},{"code":"QUEUE_FULL","retryable":true},{"code":"QUEUE_FULL_DIRTY","details":true,"retryable":false},{"code":"SESSION_CHANGED","retryable":true},{"code":"IDEMPOTENCY_CONFLICT","retryable":false},{"code":"META_CONTROL_CONFLICT","retryable":false},{"code":"COLLECT_RACE","retryable":true},{"code":"SESSION_CONFLICT","retryable":true},{"code":"INTERNAL_ERROR"}] as const diff --git a/opensquilla-webui/src/contracts/generated/v4/chatSendValidators.d.mts b/opensquilla-webui/src/contracts/generated/v4/chatSendValidators.d.mts index 279df0b16..05d31ed43 100644 --- a/opensquilla-webui/src/contracts/generated/v4/chatSendValidators.d.mts +++ b/opensquilla-webui/src/contracts/generated/v4/chatSendValidators.d.mts @@ -1,5 +1,5 @@ // @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -// source-sha256: ceb869f8ae1bab0b8d917de231dfe81a4c01923c1782a9858d84f5f28ecfae7a +// source-sha256: eeba035e6c29d2269ca3d9fa332b1805b2417de7497148a5bbff4525f36a34ed // generator-sha256: a4d9386a5dc58a59682c72a551d23aac995b95619a0d504abec20ef047671e00 export interface ContractValidator { diff --git a/opensquilla-webui/src/contracts/generated/v4/chatSendValidators.mjs b/opensquilla-webui/src/contracts/generated/v4/chatSendValidators.mjs index 65d7384f6..ef590537c 100644 --- a/opensquilla-webui/src/contracts/generated/v4/chatSendValidators.mjs +++ b/opensquilla-webui/src/contracts/generated/v4/chatSendValidators.mjs @@ -1,5 +1,5 @@ // @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -// source-sha256: ceb869f8ae1bab0b8d917de231dfe81a4c01923c1782a9858d84f5f28ecfae7a +// source-sha256: eeba035e6c29d2269ca3d9fa332b1805b2417de7497148a5bbff4525f36a34ed // generator-sha256: a4d9386a5dc58a59682c72a551d23aac995b95619a0d504abec20ef047671e00 "use strict";export const validateChatSendResult = validate20;const schema31 = {"$id":"urn:opensquilla:contract:v4:ChatSendResult","$schema":"https://json-schema.org/draft/2020-12/schema","$ref":"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult"};const schema40 = {"title":"chat.send result","description":"The acceptance response is intentionally open: deployments may add task, replay, terminal, or attachment fields without changing the v4 tree.","type":"object","additionalProperties":true,"properties":{"ok":{"type":["boolean","null"]},"status":{"type":["string","null"]},"sessionKey":{"type":["string","null"]},"session_key":{"type":["string","null"]},"key":{"type":["string","null"]},"message_id":{"type":["string","null"]},"user_message_id":{"type":["string","null"]},"client_message_id":{"type":["string","null"]},"clientMessageId":{"type":["string","null"]},"task_id":{"type":["string","null"]},"taskId":{"type":["string","null"]},"replayed":{"type":["boolean","null"]},"instant_accept":{"type":["boolean","null"]},"task_status":{"type":["string","null"]},"taskStatus":{"type":["string","null"]},"terminal_reason":{"type":["string","null"]},"terminalReason":{"type":["string","null"]},"terminal_message":{"type":["string","null"]},"terminalMessage":{"type":["string","null"]},"reason":{"type":["string","null"]},"acceptedPromptAnnotationIds":{"type":["array","null"],"items":{"type":"string"}},"accepted_prompt_annotation_ids":{"type":["array","null"],"items":{"type":"string"}}}};function validate20(data, {instancePath="", parentData, parentDataProperty, rootData=data, dynamicAnchors={}}={}){/*# sourceURL="urn:opensquilla:contract:v4:ChatSendResult" */;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.ok !== undefined){let data0 = data.ok;if((typeof data0 !== "boolean") && (data0 !== null)){const err0 = {instancePath:instancePath+"/ok",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/ok/type",keyword:"type",params:{type: schema40.properties.ok.type},message:"must be boolean,null"};if(vErrors === null){vErrors = [err0];}else {vErrors.push(err0);}errors++;}}if(data.status !== undefined){let data1 = data.status;if((typeof data1 !== "string") && (data1 !== null)){const err1 = {instancePath:instancePath+"/status",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/status/type",keyword:"type",params:{type: schema40.properties.status.type},message:"must be string,null"};if(vErrors === null){vErrors = [err1];}else {vErrors.push(err1);}errors++;}}if(data.sessionKey !== undefined){let data2 = data.sessionKey;if((typeof data2 !== "string") && (data2 !== null)){const err2 = {instancePath:instancePath+"/sessionKey",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/sessionKey/type",keyword:"type",params:{type: schema40.properties.sessionKey.type},message:"must be string,null"};if(vErrors === null){vErrors = [err2];}else {vErrors.push(err2);}errors++;}}if(data.session_key !== undefined){let data3 = data.session_key;if((typeof data3 !== "string") && (data3 !== null)){const err3 = {instancePath:instancePath+"/session_key",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/session_key/type",keyword:"type",params:{type: schema40.properties.session_key.type},message:"must be string,null"};if(vErrors === null){vErrors = [err3];}else {vErrors.push(err3);}errors++;}}if(data.key !== undefined){let data4 = data.key;if((typeof data4 !== "string") && (data4 !== null)){const err4 = {instancePath:instancePath+"/key",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/key/type",keyword:"type",params:{type: schema40.properties.key.type},message:"must be string,null"};if(vErrors === null){vErrors = [err4];}else {vErrors.push(err4);}errors++;}}if(data.message_id !== undefined){let data5 = data.message_id;if((typeof data5 !== "string") && (data5 !== null)){const err5 = {instancePath:instancePath+"/message_id",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/message_id/type",keyword:"type",params:{type: schema40.properties.message_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err5];}else {vErrors.push(err5);}errors++;}}if(data.user_message_id !== undefined){let data6 = data.user_message_id;if((typeof data6 !== "string") && (data6 !== null)){const err6 = {instancePath:instancePath+"/user_message_id",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/user_message_id/type",keyword:"type",params:{type: schema40.properties.user_message_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err6];}else {vErrors.push(err6);}errors++;}}if(data.client_message_id !== undefined){let data7 = data.client_message_id;if((typeof data7 !== "string") && (data7 !== null)){const err7 = {instancePath:instancePath+"/client_message_id",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/client_message_id/type",keyword:"type",params:{type: schema40.properties.client_message_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err7];}else {vErrors.push(err7);}errors++;}}if(data.clientMessageId !== undefined){let data8 = data.clientMessageId;if((typeof data8 !== "string") && (data8 !== null)){const err8 = {instancePath:instancePath+"/clientMessageId",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/clientMessageId/type",keyword:"type",params:{type: schema40.properties.clientMessageId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err8];}else {vErrors.push(err8);}errors++;}}if(data.task_id !== undefined){let data9 = data.task_id;if((typeof data9 !== "string") && (data9 !== null)){const err9 = {instancePath:instancePath+"/task_id",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/task_id/type",keyword:"type",params:{type: schema40.properties.task_id.type},message:"must be string,null"};if(vErrors === null){vErrors = [err9];}else {vErrors.push(err9);}errors++;}}if(data.taskId !== undefined){let data10 = data.taskId;if((typeof data10 !== "string") && (data10 !== null)){const err10 = {instancePath:instancePath+"/taskId",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/taskId/type",keyword:"type",params:{type: schema40.properties.taskId.type},message:"must be string,null"};if(vErrors === null){vErrors = [err10];}else {vErrors.push(err10);}errors++;}}if(data.replayed !== undefined){let data11 = data.replayed;if((typeof data11 !== "boolean") && (data11 !== null)){const err11 = {instancePath:instancePath+"/replayed",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/replayed/type",keyword:"type",params:{type: schema40.properties.replayed.type},message:"must be boolean,null"};if(vErrors === null){vErrors = [err11];}else {vErrors.push(err11);}errors++;}}if(data.instant_accept !== undefined){let data12 = data.instant_accept;if((typeof data12 !== "boolean") && (data12 !== null)){const err12 = {instancePath:instancePath+"/instant_accept",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/instant_accept/type",keyword:"type",params:{type: schema40.properties.instant_accept.type},message:"must be boolean,null"};if(vErrors === null){vErrors = [err12];}else {vErrors.push(err12);}errors++;}}if(data.task_status !== undefined){let data13 = data.task_status;if((typeof data13 !== "string") && (data13 !== null)){const err13 = {instancePath:instancePath+"/task_status",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/task_status/type",keyword:"type",params:{type: schema40.properties.task_status.type},message:"must be string,null"};if(vErrors === null){vErrors = [err13];}else {vErrors.push(err13);}errors++;}}if(data.taskStatus !== undefined){let data14 = data.taskStatus;if((typeof data14 !== "string") && (data14 !== null)){const err14 = {instancePath:instancePath+"/taskStatus",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/taskStatus/type",keyword:"type",params:{type: schema40.properties.taskStatus.type},message:"must be string,null"};if(vErrors === null){vErrors = [err14];}else {vErrors.push(err14);}errors++;}}if(data.terminal_reason !== undefined){let data15 = data.terminal_reason;if((typeof data15 !== "string") && (data15 !== null)){const err15 = {instancePath:instancePath+"/terminal_reason",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/terminal_reason/type",keyword:"type",params:{type: schema40.properties.terminal_reason.type},message:"must be string,null"};if(vErrors === null){vErrors = [err15];}else {vErrors.push(err15);}errors++;}}if(data.terminalReason !== undefined){let data16 = data.terminalReason;if((typeof data16 !== "string") && (data16 !== null)){const err16 = {instancePath:instancePath+"/terminalReason",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/terminalReason/type",keyword:"type",params:{type: schema40.properties.terminalReason.type},message:"must be string,null"};if(vErrors === null){vErrors = [err16];}else {vErrors.push(err16);}errors++;}}if(data.terminal_message !== undefined){let data17 = data.terminal_message;if((typeof data17 !== "string") && (data17 !== null)){const err17 = {instancePath:instancePath+"/terminal_message",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/terminal_message/type",keyword:"type",params:{type: schema40.properties.terminal_message.type},message:"must be string,null"};if(vErrors === null){vErrors = [err17];}else {vErrors.push(err17);}errors++;}}if(data.terminalMessage !== undefined){let data18 = data.terminalMessage;if((typeof data18 !== "string") && (data18 !== null)){const err18 = {instancePath:instancePath+"/terminalMessage",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/terminalMessage/type",keyword:"type",params:{type: schema40.properties.terminalMessage.type},message:"must be string,null"};if(vErrors === null){vErrors = [err18];}else {vErrors.push(err18);}errors++;}}if(data.reason !== undefined){let data19 = data.reason;if((typeof data19 !== "string") && (data19 !== null)){const err19 = {instancePath:instancePath+"/reason",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/reason/type",keyword:"type",params:{type: schema40.properties.reason.type},message:"must be string,null"};if(vErrors === null){vErrors = [err19];}else {vErrors.push(err19);}errors++;}}if(data.acceptedPromptAnnotationIds !== undefined){let data20 = data.acceptedPromptAnnotationIds;if((!(Array.isArray(data20))) && (data20 !== null)){const err20 = {instancePath:instancePath+"/acceptedPromptAnnotationIds",schemaPath:"https://opensquilla.dev/contracts/gateway/v4/conversation/chat-send.schema.json#/$defs/ChatSendResult/properties/acceptedPromptAnnotationIds/type",keyword:"type",params:{type: schema40.properties.acceptedPromptAnnotationIds.type},message:"must be array,null"};if(vErrors === null){vErrors = [err20];}else {vErrors.push(err20);}errors++;}if(Array.isArray(data20)){const len0 = data20.length;for(let i0=0; i0= 2 && parts[0] === 'cron' && Boolean(parts[1]) -} - export interface SessionPage { items: SessionItem[] hasMore: boolean diff --git a/opensquilla-webui/src/views/ChatView.vue b/opensquilla-webui/src/views/ChatView.vue index a9d54e6b9..d3446fb89 100644 --- a/opensquilla-webui/src/views/ChatView.vue +++ b/opensquilla-webui/src/views/ChatView.vue @@ -616,14 +616,7 @@ />
-
{{ t('chat.cronSessionReadOnly') }}
agentIdFromSessionKey(sessionKey.value)) -const isCronSession = computed(() => isCronSessionKey(sessionKey.value)) // True when the current draft opened with prefilled composer text (Sessions // Hub task input); the landing suggestion chips stay out of the way then. const landingPrefilled = ref(false) @@ -7080,10 +7071,4 @@ watch( height: 1px; pointer-events: none; } - -.chat-composer-read-only { - padding: 0.75rem 1rem; - color: var(--text-muted); - text-align: center; -} diff --git a/src/opensquilla/application/turn_acceptance.py b/src/opensquilla/application/turn_acceptance.py index 1849d0f3e..7f39dd07b 100644 --- a/src/opensquilla/application/turn_acceptance.py +++ b/src/opensquilla/application/turn_acceptance.py @@ -67,7 +67,6 @@ ) from opensquilla.project_workspaces import ProjectWorkspaceStateError from opensquilla.run_mode import RunMode -from opensquilla.session_key import is_cron_session_key log = structlog.get_logger(__name__) _SESSION_ROUTING_MODES = frozenset({"direct", "router", "ensemble"}) @@ -400,14 +399,6 @@ async def _accept_turn( ) return replay_response - if is_cron_session_key(key): - raise AdmissionError( - "SESSION_NOT_INTERACTIVE", - "Cron sessions are read-only and cannot receive new turns.", - retryable=False, - accepted=False, - ) - if prompt_annotation_ids or document_context_request is not None: existing_annotation_session = await storage.get_session(key) existing_collaboration_mode = ( diff --git a/src/opensquilla/contracts/generated/v4/chat_send.py b/src/opensquilla/contracts/generated/v4/chat_send.py index e509fba82..f0c790ad4 100644 --- a/src/opensquilla/contracts/generated/v4/chat_send.py +++ b/src/opensquilla/contracts/generated/v4/chat_send.py @@ -1,5 +1,5 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# source-sha256: ceb869f8ae1bab0b8d917de231dfe81a4c01923c1782a9858d84f5f28ecfae7a +# source-sha256: eeba035e6c29d2269ca3d9fa332b1805b2417de7497148a5bbff4525f36a34ed # generator-sha256: a4d9386a5dc58a59682c72a551d23aac995b95619a0d504abec20ef047671e00 # ruff: noqa diff --git a/src/opensquilla/contracts/generated/v4/chat_send_metadata.py b/src/opensquilla/contracts/generated/v4/chat_send_metadata.py index eaf39c0a9..7615f5273 100644 --- a/src/opensquilla/contracts/generated/v4/chat_send_metadata.py +++ b/src/opensquilla/contracts/generated/v4/chat_send_metadata.py @@ -1,5 +1,5 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# source-sha256: ceb869f8ae1bab0b8d917de231dfe81a4c01923c1782a9858d84f5f28ecfae7a +# source-sha256: eeba035e6c29d2269ca3d9fa332b1805b2417de7497148a5bbff4525f36a34ed # generator-sha256: a4d9386a5dc58a59682c72a551d23aac995b95619a0d504abec20ef047671e00 # ruff: noqa @@ -11,4 +11,4 @@ CHAT_SEND_IDEMPOTENCY: Final = 'idempotent' CHAT_SEND_TIMEOUT: Final = {'policy': 'caller'} CHAT_SEND_CAPABILITY: Final = {'kind': 'method-availability', 'name': 'chat.send'} -CHAT_SEND_ERRORS: Final = [{'code': 'INVALID_PARAMS'}, {'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'STORAGE_BUSY', 'retryable': True, 'retryAfterMs': True, 'details': True}, {'code': 'QUEUE_FULL', 'retryable': True}, {'code': 'QUEUE_FULL_DIRTY', 'retryable': False, 'details': True}, {'code': 'SESSION_CHANGED', 'retryable': True}, {'code': 'SESSION_NOT_INTERACTIVE', 'retryable': False}, {'code': 'IDEMPOTENCY_CONFLICT', 'retryable': False}, {'code': 'META_CONTROL_CONFLICT', 'retryable': False}, {'code': 'COLLECT_RACE', 'retryable': True}, {'code': 'SESSION_CONFLICT', 'retryable': True}, {'code': 'INTERNAL_ERROR'}] +CHAT_SEND_ERRORS: Final = [{'code': 'INVALID_PARAMS'}, {'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'STORAGE_BUSY', 'retryable': True, 'retryAfterMs': True, 'details': True}, {'code': 'QUEUE_FULL', 'retryable': True}, {'code': 'QUEUE_FULL_DIRTY', 'retryable': False, 'details': True}, {'code': 'SESSION_CHANGED', 'retryable': True}, {'code': 'IDEMPOTENCY_CONFLICT', 'retryable': False}, {'code': 'META_CONTROL_CONFLICT', 'retryable': False}, {'code': 'COLLECT_RACE', 'retryable': True}, {'code': 'SESSION_CONFLICT', 'retryable': True}, {'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 53731f55d..6f1355b31 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: c49fb6de6c57b954ba75ddd75431ac59323c265be74afa9b513f4a832d3ef9f3 +# sources-sha256: dd5e8c9dfb2d9d37bfdc04f8b211e6f48053696b794c8e6a6528a7b4f512ef6b # generator-sha256: a4d9386a5dc58a59682c72a551d23aac995b95619a0d504abec20ef047671e00 # ruff: noqa @@ -1283,7 +1283,7 @@ class GatewayEventContract: idempotency='idempotent', timeout={'policy': 'caller'}, capability={'kind': 'method-availability', 'name': 'chat.send'}, - errors=({'code': 'INVALID_PARAMS'}, {'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'STORAGE_BUSY', 'details': True, 'retryAfterMs': True, 'retryable': True}, {'code': 'QUEUE_FULL', 'retryable': True}, {'code': 'QUEUE_FULL_DIRTY', 'details': True, 'retryable': False}, {'code': 'SESSION_CHANGED', 'retryable': True}, {'code': 'SESSION_NOT_INTERACTIVE', 'retryable': False}, {'code': 'IDEMPOTENCY_CONFLICT', 'retryable': False}, {'code': 'META_CONTROL_CONFLICT', 'retryable': False}, {'code': 'COLLECT_RACE', 'retryable': True}, {'code': 'SESSION_CONFLICT', 'retryable': True}, {'code': 'INTERNAL_ERROR'}), + errors=({'code': 'INVALID_PARAMS'}, {'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'STORAGE_BUSY', 'details': True, 'retryAfterMs': True, 'retryable': True}, {'code': 'QUEUE_FULL', 'retryable': True}, {'code': 'QUEUE_FULL_DIRTY', 'details': True, 'retryable': False}, {'code': 'SESSION_CHANGED', 'retryable': True}, {'code': 'IDEMPOTENCY_CONFLICT', 'retryable': False}, {'code': 'META_CONTROL_CONFLICT', 'retryable': False}, {'code': 'COLLECT_RACE', 'retryable': True}, {'code': 'SESSION_CONFLICT', 'retryable': True}, {'code': 'INTERNAL_ERROR'}), protocol='opensquilla-websocket-json', wire_version=4, request_model=_chat_send_request_model, @@ -1411,7 +1411,7 @@ class GatewayEventContract: idempotency='idempotent', timeout={'policy': 'caller'}, capability={'kind': 'method-availability', 'name': 'sessions.send'}, - errors=({'code': 'INVALID_PARAMS'}, {'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'STORAGE_BUSY', 'details': True, 'retryAfterMs': True, 'retryable': True}, {'code': 'QUEUE_FULL', 'retryable': True}, {'code': 'QUEUE_FULL_DIRTY', 'details': True}, {'code': 'SESSION_CHANGED', 'retryable': True}, {'code': 'SESSION_NOT_INTERACTIVE', 'retryable': False}, {'code': 'IDEMPOTENCY_CONFLICT'}, {'code': 'INTERNAL_ERROR'}), + errors=({'code': 'INVALID_PARAMS'}, {'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'STORAGE_BUSY', 'details': True, 'retryAfterMs': True, 'retryable': True}, {'code': 'QUEUE_FULL', 'retryable': True}, {'code': 'QUEUE_FULL_DIRTY', 'details': True}, {'code': 'SESSION_CHANGED', 'retryable': True}, {'code': 'IDEMPOTENCY_CONFLICT'}, {'code': 'INTERNAL_ERROR'}), protocol='opensquilla-websocket-json', wire_version=4, request_model=_sessions_send_request_model, diff --git a/src/opensquilla/contracts/generated/v4/sessions_send.py b/src/opensquilla/contracts/generated/v4/sessions_send.py index 1103d93ec..a3bfe0ed9 100644 --- a/src/opensquilla/contracts/generated/v4/sessions_send.py +++ b/src/opensquilla/contracts/generated/v4/sessions_send.py @@ -1,5 +1,5 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# source-sha256: fa12a21d184536e952461a162d7e70b9023d6be53c33675587e8cee054363fad +# source-sha256: aa102df46fe1637960f66ab50e5807afadf8368f6b60fb16e9866c2fc4a71fdf # generator-sha256: a4d9386a5dc58a59682c72a551d23aac995b95619a0d504abec20ef047671e00 # ruff: noqa diff --git a/src/opensquilla/contracts/generated/v4/sessions_send_metadata.py b/src/opensquilla/contracts/generated/v4/sessions_send_metadata.py index 6458fcd51..415a03a13 100644 --- a/src/opensquilla/contracts/generated/v4/sessions_send_metadata.py +++ b/src/opensquilla/contracts/generated/v4/sessions_send_metadata.py @@ -1,5 +1,5 @@ # @generated by scripts/contracts/generate_gateway_contracts.py; do not edit. -# source-sha256: fa12a21d184536e952461a162d7e70b9023d6be53c33675587e8cee054363fad +# source-sha256: aa102df46fe1637960f66ab50e5807afadf8368f6b60fb16e9866c2fc4a71fdf # generator-sha256: a4d9386a5dc58a59682c72a551d23aac995b95619a0d504abec20ef047671e00 # ruff: noqa @@ -11,4 +11,4 @@ SESSIONS_SEND_IDEMPOTENCY: Final = 'idempotent' SESSIONS_SEND_TIMEOUT: Final = {'policy': 'caller'} SESSIONS_SEND_CAPABILITY: Final = {'kind': 'method-availability', 'name': 'sessions.send'} -SESSIONS_SEND_ERRORS: Final = [{'code': 'INVALID_PARAMS'}, {'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'STORAGE_BUSY', 'retryable': True, 'retryAfterMs': True, 'details': True}, {'code': 'QUEUE_FULL', 'retryable': True}, {'code': 'QUEUE_FULL_DIRTY', 'details': True}, {'code': 'SESSION_CHANGED', 'retryable': True}, {'code': 'SESSION_NOT_INTERACTIVE', 'retryable': False}, {'code': 'IDEMPOTENCY_CONFLICT'}, {'code': 'INTERNAL_ERROR'}] +SESSIONS_SEND_ERRORS: Final = [{'code': 'INVALID_PARAMS'}, {'code': 'INVALID_REQUEST'}, {'code': 'UNAUTHORIZED'}, {'code': 'UNAVAILABLE', 'retryable': True}, {'code': 'STORAGE_BUSY', 'retryable': True, 'retryAfterMs': True, 'details': True}, {'code': 'QUEUE_FULL', 'retryable': True}, {'code': 'QUEUE_FULL_DIRTY', 'details': True}, {'code': 'SESSION_CHANGED', 'retryable': True}, {'code': 'IDEMPOTENCY_CONFLICT'}, {'code': 'INTERNAL_ERROR'}] diff --git a/src/opensquilla/engine/turn_runner/harness.py b/src/opensquilla/engine/turn_runner/harness.py index 9a6845251..f0cb4e220 100644 --- a/src/opensquilla/engine/turn_runner/harness.py +++ b/src/opensquilla/engine/turn_runner/harness.py @@ -1743,6 +1743,7 @@ async def append_message( assistant_message_id: str | None = None, expected_session_id: str | None = None, expected_session_epoch: int | None = None, + provenance: dict[str, Any] | None = None, ) -> TranscriptAppendResult: from opensquilla.engine.runtime import _accepts_keyword_arg @@ -1769,6 +1770,8 @@ async def append_message( append_kwargs["expected_session_id"] = expected_session_id if expected_session_epoch is not None: append_kwargs["expected_session_epoch"] = expected_session_epoch + if provenance is not None: + append_kwargs["provenance"] = provenance entry = await self._runner._append_session_message(session_key, **append_kwargs) raw_message_id = getattr(entry, "message_id", None) message_id = ( diff --git a/src/opensquilla/engine/turn_runner/turn_finalizer_stage.py b/src/opensquilla/engine/turn_runner/turn_finalizer_stage.py index a6551c80a..eaff9b230 100644 --- a/src/opensquilla/engine/turn_runner/turn_finalizer_stage.py +++ b/src/opensquilla/engine/turn_runner/turn_finalizer_stage.py @@ -210,6 +210,7 @@ async def append_message( assistant_message_id: str | None = None, expected_session_id: str | None = None, expected_session_epoch: int | None = None, + provenance: dict[str, Any] | None = None, ) -> TranscriptAppendResult | bool: ... @runtime_checkable @@ -845,6 +846,13 @@ async def run( ), "token_count": token_count, } + if inp.run_kind == "cron_turn": + provenance = {"kind": "cron", "source_session_key": inp.session_key} + if isinstance(inp.input_provenance, dict): + job_id = inp.input_provenance.get("job_id") + if isinstance(job_id, str) and job_id: + provenance["source_tool"] = f"cron:{job_id}" + append_kwargs["provenance"] = provenance if assistant_message_id is not None: append_kwargs["assistant_message_id"] = assistant_message_id if inp.expected_session_id is not None: diff --git a/src/opensquilla/gateway/session_view.py b/src/opensquilla/gateway/session_view.py index c1dc252a7..1d17ebf52 100644 --- a/src/opensquilla/gateway/session_view.py +++ b/src/opensquilla/gateway/session_view.py @@ -327,7 +327,7 @@ def _last_activity_at(session: Any, task_rows: list[Any], now_ms: int) -> int: def _interactive(session_kind: str, surface: str) -> bool: - return session_kind == "chat" and surface == "webchat" + return session_kind == "cron" or (session_kind == "chat" and surface == "webchat") def _channel_context(session: Any, surface: str) -> dict[str, str] | None: diff --git a/src/opensquilla/scheduler/handlers.py b/src/opensquilla/scheduler/handlers.py index df5f9354e..d732a8b21 100644 --- a/src/opensquilla/scheduler/handlers.py +++ b/src/opensquilla/scheduler/handlers.py @@ -391,6 +391,11 @@ async def agent_run_handler(job: CronJob) -> HandlerResult: session_key, role="user", content=task, + provenance={ + "kind": "cron", + "source_session_key": session_key, + "source_tool": f"cron:{job.id}", + }, **append_kwargs, ) if _persisted is not None and isinstance(_persisted.content, str): diff --git a/src/opensquilla/session_key.py b/src/opensquilla/session_key.py index 67e481bbe..c34306b15 100644 --- a/src/opensquilla/session_key.py +++ b/src/opensquilla/session_key.py @@ -28,11 +28,4 @@ def canonicalize_session_key(session_key: str | None) -> str: return key -def is_cron_session_key(session_key: str | None) -> bool: - """Return whether a key belongs to the scheduler-owned Cron namespace.""" - - parts = canonicalize_session_key(session_key).split(":") - return len(parts) >= 2 and parts[0] == "cron" and bool(parts[1]) - - -__all__ = ["canonicalize_session_key", "is_cron_session_key"] +__all__ = ["canonicalize_session_key"] diff --git a/tests/test_application/test_turn_acceptance.py b/tests/test_application/test_turn_acceptance.py index 8a16759ed..815bab10c 100644 --- a/tests/test_application/test_turn_acceptance.py +++ b/tests/test_application/test_turn_acceptance.py @@ -15,7 +15,6 @@ from opensquilla.run_mode import RunMode from opensquilla.session.models import TurnIngressReceipt from opensquilla.session.storage import TurnAcceptanceResult -from opensquilla.session_key import is_cron_session_key @dataclass @@ -40,7 +39,7 @@ async def replay_turn_ingress_receipt(self, **identity): self.events.append("replay") assert identity == { "source_scope": "web:web:operator", - "request_session_key": self.acceptance.receipt.request_session_key, + "request_session_key": "agent:main:webchat:one", "client_request_id": "request-one", } return self.acceptance @@ -99,9 +98,9 @@ async def accepted_response(self, acceptance, **details): } -def _command(session_key: str = "agent:main:webchat:one") -> AdmitTurn: +def _command() -> AdmitTurn: return AdmitTurn( - session_key, + "agent:main:webchat:one", "synthetic message", "session", client_request_id="request-one", @@ -111,20 +110,14 @@ def _command(session_key: str = "agent:main:webchat:one") -> AdmitTurn: ) -def test_cron_namespace_match_is_case_sensitive() -> None: - assert is_cron_session_key("cron:job-one:run:one") is True - assert is_cron_session_key(" CRON:job-one:run:one ") is False - assert is_cron_session_key("cron:") is False - - -def _acceptance(session_key: str = "agent:main:webchat:one") -> TurnAcceptanceResult: +def _acceptance() -> TurnAcceptanceResult: return TurnAcceptanceResult( TurnIngressReceipt( source_scope="web:web:operator", - request_session_key=session_key, + request_session_key="agent:main:webchat:one", client_request_id="request-one", request_fingerprint="fingerprint-one", - accepted_session_key=session_key, + accepted_session_key="agent:main:webchat:one", session_id="session-one", message_id="message-one", task_id="turn-one", @@ -212,32 +205,3 @@ async def test_pending_replay_validates_and_atomically_consumes_exact_revision() } ] assert ports.events.index("consume-pending") < ports.events.index("project") - - -async def test_cron_session_rejects_fresh_turn_after_receipt_lookup() -> None: - key = "cron:job-one:run:run-one" - ports = _ReplayPorts(acceptance=_acceptance(key)) - - async def _missing_receipt(**_identity): - ports.events.append("replay") - return None - - ports.storage.replay_turn_ingress_receipt = _missing_receipt - - with pytest.raises(AdmissionError) as caught: - await DurableTurnAdmission(ports).admit(_command(key)) - - assert caught.value.kind == "SESSION_NOT_INTERACTIVE" - assert caught.value.accepted is False - assert ports.events[:4] == ["intent-enter", "authority-enter", "normalize", "replay"] - assert "project" not in ports.events - - -async def test_cron_session_replays_an_existing_accepted_turn() -> None: - key = "cron:job-one:run:run-one" - ports = _ReplayPorts(acceptance=_acceptance(key)) - - result = await DurableTurnAdmission(ports).admit(_command(key)) - - assert result["replayed"] is True - assert ports.events.index("replay") < ports.events.index("project") diff --git a/tests/test_engine/turn_runner/test_cron_provenance.py b/tests/test_engine/turn_runner/test_cron_provenance.py new file mode 100644 index 000000000..cf1e1b5fe --- /dev/null +++ b/tests/test_engine/turn_runner/test_cron_provenance.py @@ -0,0 +1,181 @@ +"""Scheduled turns retain their source through the real transcript writer.""" + +from types import SimpleNamespace +from typing import Any + +import pytest + +from opensquilla.engine.turn_runner.harness import _TurnRunnerTranscriptAppendAdapter +from opensquilla.engine.turn_runner.turn_finalizer_stage import ( + TurnFinalizerStage, + TurnFinalizerStageInput, +) +from opensquilla.engine.types import DoneEvent +from opensquilla.scheduler.delivery import DeliveryChain +from opensquilla.scheduler.handlers import make_agent_run_handler +from opensquilla.scheduler.types import CronJob, SessionTarget +from opensquilla.session.manager import SessionManager +from opensquilla.session.models import SessionIntent +from opensquilla.session.storage import SessionStorage, StaleEpochError + + +class _NoopEffects: + async def capture_turn(self, **_: Any) -> None: + pass + + async def persist_error(self, **_: Any) -> None: + pass + + async def rollup(self, **_: Any) -> None: + pass + + +async def _finish( + manager: SessionManager, + session_key: str, + *, + run_kind: str, + input_provenance: dict[str, Any] | None, + expected_session_id: str, + expected_session_epoch: int, +) -> None: + runner = SimpleNamespace( + _session_manager=manager, + _append_session_message=manager.append_message, + ) + effects = _NoopEffects() + stage = TurnFinalizerStage( + transcript_append=_TurnRunnerTranscriptAppendAdapter(runner), + turn_memory_capture=effects, + turn_error_persist=effects, + session_totals=effects, + ) + await stage.run(TurnFinalizerStageInput( + final_text_parts=["Scheduled inventory: 12 items."], + turn_segments=[], + turn_artifacts=[], + error_message=None, + pending_error_event=None, + done_event=None, + runtime_message="Count the synthetic inventory.", + input_mode="user", + input_provenance=input_provenance, + resolved_model="synthetic-model", + agent_id="main", + session_key=session_key, + tool_context=None, + run_kind=run_kind, + heartbeat_ack_max_chars=300, + no_memory_capture=True, + expected_session_id=expected_session_id, + expected_session_epoch=expected_session_epoch, + )) + + +class _ScheduledRunner: + def __init__(self, manager: SessionManager) -> None: + self.manager = manager + + async def run( + self, + message: str, + session_key: str, + *, + run_kind: str, + input_provenance: dict[str, Any], + expected_session_id: str, + expected_session_epoch: int, + **_: Any, + ): + await _finish( + self.manager, + session_key, + run_kind=run_kind, + input_provenance=input_provenance, + expected_session_id=expected_session_id, + expected_session_epoch=expected_session_epoch, + ) + yield DoneEvent(text_snapshot="Scheduled inventory: 12 items.") + + +@pytest.mark.parametrize("target", [SessionTarget.CURRENT, SessionTarget.ISOLATED]) +async def test_cron_prompt_and_reply_sources_survive_reopen(tmp_path, target) -> None: + database = str(tmp_path / "sessions.db") + storage = SessionStorage(database) + await storage.connect() + manager = SessionManager(storage, inject_time_prefix=False) + origin = "agent:main:webchat:inventory" + original = await manager.create(origin) + await manager.append_message(origin, "user", "There are 12 items.") + job = CronJob( + id="inventory-check", + name="Inventory check", + handler_key="agent_run", + payload={"kind": "agent_turn", "task": "Count the synthetic inventory."}, + session_target=target, + session_key=origin if target == SessionTarget.CURRENT else "", + origin_session_key=origin, + ) + handler = make_agent_run_handler( + DeliveryChain(), + turn_runner_ref=lambda: _ScheduledRunner(manager), + session_manager_ref=lambda: manager, + ) + try: + result = await handler(job) + run_key = result.session_key + entries = await manager.get_transcript(run_key) + assert [entry.role for entry in entries[-2:]] == ["user", "assistant"] + for entry in entries[-2:]: + assert entry.provenance_kind == "cron" + assert entry.provenance_source_tool == "cron:inventory-check" + assert entry.provenance_source_session_key == run_key + assert (await storage.get_session(origin)).session_id == original.session_id + if target == SessionTarget.CURRENT: + assert entries[0].content == "There are 12 items." + assert entries[0].provenance_kind is None + message_ids = [entry.message_id for entry in entries] + finally: + await storage.close() + + reopened = SessionStorage(database) + await reopened.connect() + try: + history = await SessionManager(reopened).get_transcript(run_key) + assert [entry.message_id for entry in history] == message_ids + assert [entry.provenance_kind for entry in history[-2:]] == ["cron", "cron"] + owner = await reopened.get_session(run_key) + await _finish( + SessionManager(reopened), + run_key, + run_kind="session_turn", + input_provenance={"kind": "runtime_send"}, + expected_session_id=owner.session_id, + expected_session_epoch=owner.epoch, + ) + continued = await SessionManager(reopened).get_transcript(run_key) + assert continued[-1].provenance_kind is None + finally: + await reopened.close() + + +async def test_cron_reply_source_does_not_bypass_session_owner(tmp_path) -> None: + storage = SessionStorage(str(tmp_path / "sessions.db")) + await storage.connect() + manager = SessionManager(storage, inject_time_prefix=False) + key = "agent:main:webchat:reset-inventory" + try: + owner = await manager.create(key) + await manager.apply_intent(key, SessionIntent.RESET_SAME_KEY) + with pytest.raises(StaleEpochError): + await _finish( + manager, + key, + run_kind="cron_turn", + input_provenance={"kind": "cron_job", "job_id": "inventory-check"}, + expected_session_id=owner.session_id, + expected_session_epoch=owner.epoch, + ) + assert await manager.get_transcript(key) == [] + finally: + await storage.close() diff --git a/tests/test_gateway/test_context_overflow.py b/tests/test_gateway/test_context_overflow.py index 66935c454..ace465445 100644 --- a/tests/test_gateway/test_context_overflow.py +++ b/tests/test_gateway/test_context_overflow.py @@ -51,17 +51,19 @@ async def compact(self, session_key: str, budget: int, config=None) -> str: # Simulate a successful compaction: collapse history into a single # short summary entry so the next estimate fits easily. self.compact_calls.append((session_key, budget, config)) - self._transcript = [_FakeEntry(content="[summary]")] - return "[summary]" + self._transcript = [_FakeEntry(content="ok")] + return "ok" class _ResultCompactionSessionManager(_FakeSessionManager): async def compact_with_result(self, session_key: str, budget: int, config=None, **kwargs): self.compact_calls.append((session_key, budget, config)) self.compact_kwargs.append(dict(kwargs)) - self._transcript = [_FakeEntry(content="[summary]")] + # The success fixture must fit the tiny test budget even when the + # optional tokenizer is unavailable and conservative estimation is used. + self._transcript = [_FakeEntry(content="ok")] return SimpleNamespace( - summary="[summary]", + summary="ok", kept_entries=[{"role": "assistant", "content": "[tail]"}], removed_count=5, chunks_processed=2, @@ -104,8 +106,8 @@ async def compact(self, session_key: str, budget: int, config=None) -> str: class _LegacyCompactSessionManager(_FakeSessionManager): async def compact(self, session_key: str, budget: int) -> str: self.compact_calls.append((session_key, budget, None)) - self._transcript = [_FakeEntry(content="[summary]")] - return "[summary]" + self._transcript = [_FakeEntry(content="ok")] + return "ok" def _assert_armed_compact_call( @@ -542,6 +544,30 @@ async def test_auto_summarize_preserves_root_and_splits_auxiliary_executions() - assert flush_correlation.call_kind == "auxiliary.session_flush" +@pytest.mark.asyncio +@pytest.mark.parametrize( + "manager_type", + [_FakeSessionManager, _ResultCompactionSessionManager, _LegacyCompactSessionManager], +) +async def test_auto_summarize_small_result_without_optional_tokenizer( + monkeypatch, manager_type, +) -> None: + from opensquilla import token_estimation + + monkeypatch.setattr(token_estimation, "_get_encoding", lambda: None) + manager = manager_type(_history(6, 40)) + outcome = await apply_context_overflow_policy( + config=_cfg(ContextOverflowPolicy.AUTO_SUMMARIZE, budget=10), + message="m", + transcript=manager._transcript, + session_key="s-fallback-tokenizer", + session_manager=manager, + ) + + assert outcome.summarized is True + assert outcome.tokens_after <= 10 + + @pytest.mark.asyncio async def test_auto_summarize_checkpoint_runs_before_compact() -> None: sm = _CheckpointingSessionManager(_history(6, 40)) diff --git a/tests/test_gateway/test_rpc_cron_current_session.py b/tests/test_gateway/test_rpc_cron_current_session.py index a5acd93b0..c9cd47572 100644 --- a/tests/test_gateway/test_rpc_cron_current_session.py +++ b/tests/test_gateway/test_rpc_cron_current_session.py @@ -957,7 +957,15 @@ async def forwarder(**kwargs) -> None: assert "exec_command" in tool_context.denied_tools assert "web_fetch" in tool_context.denied_tools assert await session_manager.read_transcript(SESSION_KEY) == [ - {"role": "user", "content": "drink water"}, + { + "role": "user", + "content": "drink water", + "provenance": { + "kind": "cron", + "source_session_key": SESSION_KEY, + "source_tool": "cron:drink", + }, + }, {"role": "assistant", "content": "drink logged"}, ] assert forward_calls == [] @@ -1201,7 +1209,15 @@ async def test_current_webchat_agent_run_treats_same_session_transcript_as_deliv assert result.summary == "drink logged" assert result.delivery_status == "delivered|ws:skipped|fwd:skipped" assert await session_manager.read_transcript(SESSION_KEY) == [ - {"role": "user", "content": "drink water"}, + { + "role": "user", + "content": "drink water", + "provenance": { + "kind": "cron", + "source_session_key": SESSION_KEY, + "source_tool": "cron:drink", + }, + }, {"role": "assistant", "content": "drink logged"}, ] diff --git a/tests/test_gateway/test_rpc_sessions.py b/tests/test_gateway/test_rpc_sessions.py index 033a1c3c3..0f99a7294 100644 --- a/tests/test_gateway/test_rpc_sessions.py +++ b/tests/test_gateway/test_rpc_sessions.py @@ -2212,9 +2212,12 @@ async def test_list_contract_run_status_matches_legacy_interrupted_state(self, d assert row["runStatus"] == "interrupted" @pytest.mark.asyncio - async def test_list_contract_cron_isolated_row(self, dispatcher): + @pytest.mark.parametrize( + "session_key", ["cron:daily-summary", "cron:daily-summary:run:abc123"] + ) + async def test_list_contract_cron_isolated_row(self, dispatcher, session_key): session = FakeSession( - session_key="cron:daily-summary:run:abc123", + session_key=session_key, display_name="Daily summary", origin={ "kind": "cron", @@ -2232,7 +2235,7 @@ async def test_list_contract_cron_isolated_row(self, dispatcher): assert row["sessionKind"] == "cron" assert row["surface"] == "cron" assert row["groupLabel"] == "Cron" - assert row["interactive"] is False + assert row["interactive"] is True assert row["cron"] == { "jobId": "daily-summary", "sessionTarget": "isolated", diff --git a/tests/test_gateway/test_turn_ingress_rpc.py b/tests/test_gateway/test_turn_ingress_rpc.py index 58e8bb2c5..911ec607d 100644 --- a/tests/test_gateway/test_turn_ingress_rpc.py +++ b/tests/test_gateway/test_turn_ingress_rpc.py @@ -372,48 +372,6 @@ async def accepted_config_provider(*, session_key: str, run_kind: str) -> Any: assert audit["effective_mode"] == "router" -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("method", "key_field"), - [("chat.send", "sessionKey"), ("sessions.send", "key")], -) -async def test_public_send_rejects_cron_session_without_acceptance_side_effects( - tmp_path: Path, - method: str, - key_field: str, -) -> None: - async with _open_real_stack(tmp_path / "cron-send.db") as stack: - cron_key = "cron:job-1:run:run-1" - await stack.manager.create( - cron_key, - agent_id="main", - display_name="Cron run", - ) - - response = await get_dispatcher().dispatch( - f"rpc-cron-send-{method}", - method, - { - key_field: cron_key, - "message": "unexpected follow-up", - "clientRequestId": f"cron-send-{method}", - }, - stack.context, - ) - - assert response.ok is False - assert response.error is not None - assert response.error.code == "SESSION_NOT_INTERACTIVE" - assert response.error.retryable is False - assert response.error.accepted is False - assert _table_counts(stack.db_path) == { - "transcript_entries": 0, - "agent_tasks": 0, - "turn_ingress_receipts": 0, - } - _assert_no_runtime_acceptance_state(stack.runtime) - - @pytest.mark.asyncio @pytest.mark.parametrize( ("message", "display_text"), @@ -4201,3 +4159,96 @@ async def test_chat_send_forwards_client_request_id_into_atomic_acceptance( "agent_tasks": 1, "turn_ingress_receipts": 1, } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("method", "key_field"), + [("chat.send", "sessionKey"), ("sessions.send", "key")], +) +@pytest.mark.parametrize("cron_key", ["cron:inventory", "cron:inventory:run:first"]) +async def test_public_send_continues_automation_session_with_queue_and_replay( + tmp_path: Path, + method: str, + key_field: str, + cron_key: str, +) -> None: + db_path = tmp_path / "automation-chat.db" + async with _open_real_stack(db_path) as stack: + session = await stack.manager.create(cron_key, agent_id="main") + for role, content in [("user", "Check the sample inventory"), ("assistant", "Count: 12")]: + await stack.manager.append_message( + cron_key, role=role, content=content, provenance={"kind": "cron"} + ) + original = await stack.storage.get_transcript(session.session_id) + first = await get_dispatcher().dispatch( + "automation-first", + method, + { + key_field: cron_key, + "message": "Explain the inventory count", + "clientRequestId": "automation-first", + "queueMode": "followup", + }, + stack.context, + ) + assert first.ok is True + await stack.wait_until_running() + + params = { + key_field: cron_key, + "message": "Include the sample categories", + "clientRequestId": "automation-second", + "queueMode": "followup", + } + second = await get_dispatcher().dispatch("automation-second", method, params, stack.context) + replay = await get_dispatcher().dispatch("automation-replay", method, params, stack.context) + + assert second.ok is True + assert replay.ok is True + assert replay.payload["replayed"] is True + assert replay.payload["task_id"] == second.payload["task_id"] + assert replay.payload["message_id"] == second.payload["message_id"] + assert len(stack.received_runs) == 1 + pending = await stack.storage.get_agent_task(second.payload["task_id"]) + assert pending is not None + assert str(pending.status) == "queued" + assert pending.details["session_id"] == session.session_id + assert pending.details["session_epoch"] == session.epoch + + stack.release_handler.set() + for accepted in (first, second): + terminal = await stack.runtime.wait(accepted.payload["task_id"], timeout=2.0) + assert str(terminal.status) == "succeeded" + assert len(stack.received_runs) == 2 + for run in stack.received_runs: + assert run.envelope.session_key == cron_key + assert run.envelope.session_id == session.session_id + assert run.envelope.session_epoch == session.epoch + assert run.run_kind == "session_turn" + + entries = await stack.storage.get_transcript(session.session_id) + assert [entry.model_dump() for entry in entries[:2]] == [ + entry.model_dump() for entry in original + ] + assert [entry.content for entry in entries[2:]] == [ + "Explain the inventory count", + "Include the sample categories", + ] + assert _table_counts(db_path) == { + "transcript_entries": 4, + "agent_tasks": 2, + "turn_ingress_receipts": 2, + } + + reopened = await SessionStorage.open(str(db_path)) + try: + restored = await reopened.get_session(cron_key) + assert restored is not None + assert (restored.session_id, restored.epoch) == (session.session_id, session.epoch) + restored_entries = await reopened.get_transcript(session.session_id) + assert [entry.model_dump() for entry in restored_entries] == [ + entry.model_dump() for entry in entries + ] + finally: + await reopened.close()