Fix Chrome session list API pagination in IDE beta 9
This commit is contained in:
1 parent
fa63e03eb7
commit
ff2eb47302
7 files changed
+189
-8
No files matched your search
+15
-2
@@ -1,7 +1,7 @@
|
|||||||
# AITURK Chrome beta
|
# AITURK Chrome beta
|
||||||
|
|
||||||
Windows / Chrome MV3 side panel for existing local AITURK IDE conversations.
|
Windows / Chrome MV3 side panel for existing local AITURK IDE conversations.
|
||||||
Requires AITURK IDE 1.0.0-beta.7 or later; see `KURULUM.txt` for Turkish setup.
|
Requires AITURK IDE 1.0.0-beta.9 or later; see `KURULUM.txt` for Turkish setup.
|
||||||
|
|
||||||
The extension talks only to `online.turkservis.aiturk` using Chrome native
|
The extension talks only to `online.turkservis.aiturk` using Chrome native
|
||||||
messaging. The per-user .NET Framework host authenticates to Electron's random
|
messaging. The per-user .NET Framework host authenticates to Electron's random
|
||||||
@@ -12,7 +12,8 @@ runtime identity, then submits only the new prompt. No parallel agent backend,
|
|||||||
history reconstruction, model API key in the browser or telemetry is involved.
|
history reconstruction, model API key in the browser or telemetry is involved.
|
||||||
|
|
||||||
Only the active local profile is supported. The list searches titles among
|
Only the active local profile is supported. The list searches titles among
|
||||||
desktop sessions in the most recent 200 records. The transcript shows the last
|
the most recent 200 desktop sessions (two API pages of at most 100, filtered
|
||||||
|
server-side before pagination). The transcript shows the last
|
||||||
40 records, with up to 8,000 characters per visible message; the model's existing
|
40 records, with up to 8,000 characters per visible message; the model's existing
|
||||||
context is not truncated by this display policy. Approvals and clarifications
|
context is not truncated by this display policy. Approvals and clarifications
|
||||||
remain in the IDE. A backend event is stamped once and delivered to every
|
remain in the IDE. A backend event is stamped once and delivered to every
|
||||||
@@ -40,6 +41,18 @@ npm ci --workspace apps/desktop --include-workspace-root
|
|||||||
node --test apps/chrome/tests/panel.test.mjs
|
node --test apps/chrome/tests/panel.test.mjs
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Exercise the production FastAPI session router and real temporary SQLite stores
|
||||||
|
with the JS bridge (no model requests or user data):
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:AITURK_TEST_PYTHON = 'C:/path/to/python-with-hermes-dependencies.exe'
|
||||||
|
node --test apps/chrome/tests/sessions-api.test.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
This contract test reproduces the original 422 for an oversized page, then
|
||||||
|
verifies 200 desktop conversations across two valid pages, profile isolation,
|
||||||
|
short/empty lists, archived/empty/cron exclusion and bounded pagination.
|
||||||
|
|
||||||
Run backend fan-out and replay tests with the canonical hermetic runner:
|
Run backend fan-out and replay tests with the canonical hermetic runner:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -94,6 +94,11 @@ async function fixture(t) {
|
|||||||
const http = createServer((request, response) => {
|
const http = createServer((request, response) => {
|
||||||
assert.equal(request.headers['x-hermes-session-token'], 'fixture-backend-secret')
|
assert.equal(request.headers['x-hermes-session-token'], 'fixture-backend-secret')
|
||||||
response.setHeader('Content-Type', 'application/json')
|
response.setHeader('Content-Type', 'application/json')
|
||||||
|
if (request.url.startsWith('/api/sessions?')) {
|
||||||
|
const query = new URL(request.url, 'http://localhost').searchParams
|
||||||
|
assert.ok(Number(query.get('limit')) <= 100)
|
||||||
|
assert.equal(query.get('source'), 'desktop')
|
||||||
|
}
|
||||||
if (request.url.startsWith('/api/sessions?')) response.end(JSON.stringify({ sessions: [
|
if (request.url.startsWith('/api/sessions?')) response.end(JSON.stringify({ sessions: [
|
||||||
{ id: 'stored-1', title: 'Birinci iş', source: 'desktop', model: 'fixture-model' },
|
{ id: 'stored-1', title: 'Birinci iş', source: 'desktop', model: 'fixture-model' },
|
||||||
{ id: 'stored-2', title: 'İkinci iş', source: 'desktop' },
|
{ id: 'stored-2', title: 'İkinci iş', source: 'desktop' },
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Real session router + disposable SQLite stores for the JS bridge contract.
|
||||||
|
|
||||||
|
Run by sessions-api.test.mjs. Only service dependencies are replaced; query
|
||||||
|
validation, route handling, SQL filters and pagination are production code.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[3]))
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="aiturk-chrome-api-") as directory:
|
||||||
|
os.environ["HERMES_HOME"] = directory
|
||||||
|
from fastapi import FastAPI, HTTPException, WebSocket
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
import uvicorn
|
||||||
|
from hermes_state import SessionDB
|
||||||
|
from hermes_cli.web_routers import sessions
|
||||||
|
|
||||||
|
paths = {name: Path(directory) / f"{name}.db" for name in ("default", "small", "empty")}
|
||||||
|
for profile, count in (("default", 230), ("small", 3), ("empty", 0)):
|
||||||
|
db = SessionDB(db_path=paths[profile])
|
||||||
|
for index in range(count):
|
||||||
|
sid = f"{profile}-desktop-{index:03d}"
|
||||||
|
db.create_session(sid, source="desktop")
|
||||||
|
db.set_session_title(sid, sid)
|
||||||
|
db.append_message(sid, role="user", content="Fixture context")
|
||||||
|
if count:
|
||||||
|
for index in range(240):
|
||||||
|
sid = f"{profile}-cron-{index:03d}"
|
||||||
|
db.create_session(sid, source="cron")
|
||||||
|
db.set_session_title(sid, sid)
|
||||||
|
db.append_message(sid, role="user", content="Not a desktop conversation")
|
||||||
|
db.create_session(f"{profile}-blank", source="desktop")
|
||||||
|
db.create_session(f"{profile}-archived", source="desktop")
|
||||||
|
db.append_message(f"{profile}-archived", role="user", content="Archived")
|
||||||
|
db._conn.execute("UPDATE sessions SET archived=1 WHERE id=?", (f"{profile}-archived",))
|
||||||
|
db._conn.commit()
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
def profile_home(profile):
|
||||||
|
if profile not in paths:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
return profile, Path(directory)
|
||||||
|
|
||||||
|
sessions._cron_profile_home = profile_home
|
||||||
|
sessions._cron_default_profile = lambda: "default"
|
||||||
|
sessions._maybe_auto_archive_for_profile = lambda profile: None
|
||||||
|
sessions._open_session_db_for_profile = lambda profile, read_only=True: SessionDB(
|
||||||
|
db_path=paths[profile or "default"], read_only=read_only
|
||||||
|
)
|
||||||
|
# The fixture stores no system/model secrets; list projection is unrelated
|
||||||
|
# to this contract and must not import the full web-server startup path.
|
||||||
|
sessions._strip_session_list_rows = lambda rows: None
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(sessions.list_router)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def auth(request, call_next):
|
||||||
|
if request.headers.get("X-Hermes-Session-Token") != "fixture-only":
|
||||||
|
return JSONResponse({}, status_code=401)
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
@app.websocket("/api/ws")
|
||||||
|
async def ws(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
await websocket.receive_text()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
sock = socket.socket()
|
||||||
|
sock.bind(("127.0.0.1", 0))
|
||||||
|
port = sock.getsockname()[1]
|
||||||
|
server = uvicorn.Server(uvicorn.Config(app, log_level="critical"))
|
||||||
|
thread = threading.Thread(target=server.run, kwargs={"sockets": [sock]}, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
deadline = time.monotonic() + 20
|
||||||
|
while not server.started:
|
||||||
|
if not thread.is_alive() or time.monotonic() > deadline:
|
||||||
|
raise RuntimeError("Fixture failed to start")
|
||||||
|
time.sleep(0.01)
|
||||||
|
print(f"READY {port}", flush=True)
|
||||||
|
# EOF shuts down cleanly, including Windows venv launcher/worker pairs.
|
||||||
|
sys.stdin.read()
|
||||||
|
server.should_exit = True
|
||||||
|
thread.join(timeout=15)
|
||||||
|
sock.close()
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { spawn } from 'node:child_process'
|
||||||
|
import { once } from 'node:events'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { ChromeGatewayClient } from '../../desktop/electron/chrome-bridge.mjs'
|
||||||
|
|
||||||
|
test('real FastAPI/SQLite: valid bounded desktop pages, profile scope and empty states', {
|
||||||
|
skip: !process.env.AITURK_TEST_PYTHON && 'Set AITURK_TEST_PYTHON to run the real API contract',
|
||||||
|
timeout: 90000,
|
||||||
|
}, async t => {
|
||||||
|
// No credentials/config inherited by the disposable Python service.
|
||||||
|
const env = Object.fromEntries(['PATH', 'SYSTEMROOT', 'TEMP', 'TMP', 'USERPROFILE', 'LOCALAPPDATA', 'APPDATA']
|
||||||
|
.filter(key => process.env[key]).map(key => [key, process.env[key]]))
|
||||||
|
env.PYTHONUTF8 = '1'
|
||||||
|
const child = spawn(process.env.AITURK_TEST_PYTHON,
|
||||||
|
[fileURLToPath(new URL('./sessions-api-fixture.py', import.meta.url))], { env, windowsHide: true })
|
||||||
|
const exited = once(child, 'exit')
|
||||||
|
let stdout = '', stderr = ''
|
||||||
|
child.stderr.on('data', chunk => { stderr += chunk })
|
||||||
|
t.after(async () => { child.stdin.end(); await exited })
|
||||||
|
const port = await new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error(`Fixture startup timeout: ${stderr}`)), 45000)
|
||||||
|
child.once('error', error => { clearTimeout(timer); reject(error) })
|
||||||
|
child.once('exit', () => { clearTimeout(timer); reject(new Error(`Fixture exited: ${stderr}`)) })
|
||||||
|
child.stdout.on('data', chunk => {
|
||||||
|
stdout += chunk
|
||||||
|
const match = stdout.match(/READY (\d+)/)
|
||||||
|
if (match) { clearTimeout(timer); resolve(Number(match[1])) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`
|
||||||
|
const old = await fetch(`${baseUrl}/api/sessions?limit=200`, { headers: { 'X-Hermes-Session-Token': 'fixture-only' } })
|
||||||
|
assert.equal(old.status, 422, 'production validation reproduces the reported failure')
|
||||||
|
assert.ok((await old.json()).detail.some(error => error.loc.includes('limit')))
|
||||||
|
for (const [profile, expected, requests] of [['default', 200, 2], ['small', 3, 1], ['empty', 0, 1]]) {
|
||||||
|
const client = new ChromeGatewayClient(async () => ({ baseUrl, wsUrl: `${baseUrl.replace('http:', 'ws:')}/api/ws`,
|
||||||
|
token: 'fixture-only', mode: 'local', profile }), () => {})
|
||||||
|
const paths = []
|
||||||
|
const realRest = client.rest.bind(client)
|
||||||
|
client.rest = path => { paths.push(path); return realRest(path) }
|
||||||
|
try {
|
||||||
|
const result = await client.handle({ id: 'contract', action: 'sessions.list' })
|
||||||
|
assert.equal(result.sessions.length, expected)
|
||||||
|
assert.equal(new Set(result.sessions.map(row => row.id)).size, expected)
|
||||||
|
assert.ok(result.sessions.every(row => row.id.startsWith(`${profile}-desktop-`)))
|
||||||
|
assert.equal(result.profile, profile)
|
||||||
|
assert.equal(paths.length, requests)
|
||||||
|
for (const [index, path] of paths.entries()) {
|
||||||
|
const query = new URL(path, baseUrl).searchParams
|
||||||
|
assert.equal(query.get('offset'), String(index * 100))
|
||||||
|
assert.equal(query.get('source'), 'desktop')
|
||||||
|
assert.equal(query.get('profile'), profile)
|
||||||
|
}
|
||||||
|
} finally { client.close() }
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -159,10 +159,23 @@ export class ChromeGatewayClient {
|
|||||||
case 'status':
|
case 'status':
|
||||||
return { product: 'AITURK IDE', protocol: 1, profile: this.backend.profile || 'default', local: true }
|
return { product: 'AITURK IDE', protocol: 1, profile: this.backend.profile || 'default', local: true }
|
||||||
case 'sessions.list': {
|
case 'sessions.list': {
|
||||||
const query = new URLSearchParams({ ...this.scope(), limit: '200', offset: '0', min_messages: '1', archived: 'exclude', order: 'recent' })
|
const rows = [], seen = new Set()
|
||||||
const data = await this.rest(`/api/sessions?${query}`)
|
// The real sessions API validates limit <= 100. Filter before paging:
|
||||||
const rows = Array.isArray(data) ? data : data.sessions || []
|
// cron/CLI traffic must not crowd IDE conversations out of the list.
|
||||||
return { sessions: rows.slice(0, 200).filter(row => row.source === 'desktop').map(row => ({
|
for (let offset = 0; offset < 200; offset += 100) {
|
||||||
|
const query = new URLSearchParams({ ...this.scope(), limit: '100', offset: String(offset),
|
||||||
|
source: 'desktop', min_messages: '1', archived: 'exclude', order: 'recent' })
|
||||||
|
const data = await this.rest(`/api/sessions?${query}`)
|
||||||
|
const page = Array.isArray(data) ? data : data.sessions || []
|
||||||
|
for (const row of page) {
|
||||||
|
const id = row.id || row.session_id
|
||||||
|
if (row.source !== 'desktop' || !id || seen.has(id)) continue
|
||||||
|
seen.add(id)
|
||||||
|
rows.push(row)
|
||||||
|
}
|
||||||
|
if (page.length < 100) break
|
||||||
|
}
|
||||||
|
return { sessions: rows.slice(0, 200).map(row => ({
|
||||||
id: row.id || row.session_id, title: clip(row.title || row.preview || 'İsimsiz konuşma', 180),
|
id: row.id || row.session_id, title: clip(row.title || row.preview || 'İsimsiz konuşma', 180),
|
||||||
model: clip(row.model, 160), updatedAt: row.last_active || row.started_at,
|
model: clip(row.model, 160), updatedAt: row.last_active || row.started_at,
|
||||||
})), profile: this.backend.profile || 'default', limited: true }
|
})), profile: this.backend.profile || 'default', limited: true }
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"productName": "AITURK IDE",
|
"productName": "AITURK IDE",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0-beta.8",
|
"version": "1.0.0-beta.9",
|
||||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||||
"author": "AITURK / TurkServis",
|
"author": "AITURK / TurkServis",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
|||||||
},
|
},
|
||||||
"apps/desktop": {
|
"apps/desktop": {
|
||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"version": "1.0.0-beta.8",
|
"version": "1.0.0-beta.9",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@assistant-ui/core": "0.2.23",
|
"@assistant-ui/core": "0.2.23",
|
||||||
|
|||||||
Reference in new issue
Block a user