Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions contracts/gateway/v4/compatibility-manifest.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@
"lifecycle": "stable",
"name": "chat.send",
"schema": "conversation/chat-send.schema.json",
"schemaSha256": "85a8137aeff9a9c5f6ac3491dfadbfdf8050935f019d4bdab0cee78ce13c48d9"
"schemaSha256": "ee0da1e5d87a98bcd4c73cffc666b71f76f9d0500fb50f245ad251f549a47a17"
},
{
"lifecycle": "stable",
Expand Down Expand Up @@ -1295,7 +1295,7 @@
"lifecycle": "stable",
"name": "sessions.send",
"schema": "conversation/sessions-send.schema.json",
"schemaSha256": "7a1fb4de4d1a5ea5714703bcf21d15ccec633d8b8f3a4ea55319e10a04d7435e"
"schemaSha256": "3382c9945ecbccda159ee5389b93af498ffdad1aa5716aedf961feb553b82ce7"
},
{
"canonicalName": "sessions.steer.v2",
Expand Down Expand Up @@ -1464,7 +1464,7 @@
"generatorSha256": "a4d9386a5dc58a59682c72a551d23aac995b95619a0d504abec20ef047671e00",
"methodCount": 215,
"schemaCount": 224,
"schemaTreeSha256": "4fe17977e4b9ba7c15531c8232d68e8f47c3927ca4e9d631b5efc1a2172b54fb"
"schemaTreeSha256": "4cad213cb5943a1f97618e7e6e6526761afe9e4a69db83ae2d5519309202be18"
},
"wireVersion": 4
}
1 change: 0 additions & 1 deletion contracts/gateway/v4/conversation/chat-send.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
]
},
Expand Down
158 changes: 158 additions & 0 deletions opensquilla-webui/e2e/history-hydration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> }> = []
const history: Array<Record<string, unknown>> = [{
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<string, unknown> = {
'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)))
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createV4SessionDirectory>[0]
Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
})
})
20 changes: 20 additions & 0 deletions opensquilla-webui/src/components/chat/UserMessage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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. -->
<div class="msg-user-stack">
<span
v-if="message.provenanceKind === 'cron'"
class="msg-user-cron-source"
data-testid="cron-input-source"
>
<Icon name="cron" :size="12" aria-hidden="true" />
{{ t('chat.provenance.scheduledTrigger') }}
</span>
<div
v-if="message.promptAnnotations?.length"
class="msg-prompt-annotations"
Expand Down Expand Up @@ -525,6 +533,18 @@ function activateAttachment(attachment: DisplayAttachment) {
min-width: 0;
}

.msg-user-cron-source {
display: inline-flex;
align-items: center;
gap: var(--sp-1);
padding: 1px var(--sp-2);
border: 1px solid color-mix(in srgb, var(--accent) 26%, var(--border));
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface));
color: var(--text-muted);
font-size: var(--fs-xs);
}

.msg-user-steer-status {
display: inline-flex;
align-items: center;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ function renderedMessagesFor(
})
}

describe('useChatRenderedMessages scheduled provenance', () => {
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([{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions opensquilla-webui/src/contracts/generated/v4/chatSend.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// @generated by scripts/contracts/generate_gateway_contracts.py; do not edit.
// source-sha256: ceb869f8ae1bab0b8d917de231dfe81a4c01923c1782a9858d84f5f28ecfae7a
// source-sha256: eeba035e6c29d2269ca3d9fa332b1805b2417de7497148a5bbff4525f36a34ed
// generator-sha256: a4d9386a5dc58a59682c72a551d23aac995b95619a0d504abec20ef047671e00

/**
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
Loading
Loading