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
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
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
|
||||
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
|
||||
@@ -40,6 +41,18 @@ npm ci --workspace apps/desktop --include-workspace-root
|
||||
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:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -94,6 +94,11 @@ async function fixture(t) {
|
||||
const http = createServer((request, response) => {
|
||||
assert.equal(request.headers['x-hermes-session-token'], 'fixture-backend-secret')
|
||||
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: [
|
||||
{ id: 'stored-1', title: 'Birinci iş', source: 'desktop', model: 'fixture-model' },
|
||||
{ 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':
|
||||
return { product: 'AITURK IDE', protocol: 1, profile: this.backend.profile || 'default', local: true }
|
||||
case 'sessions.list': {
|
||||
const query = new URLSearchParams({ ...this.scope(), limit: '200', offset: '0', min_messages: '1', archived: 'exclude', order: 'recent' })
|
||||
const data = await this.rest(`/api/sessions?${query}`)
|
||||
const rows = Array.isArray(data) ? data : data.sessions || []
|
||||
return { sessions: rows.slice(0, 200).filter(row => row.source === 'desktop').map(row => ({
|
||||
const rows = [], seen = new Set()
|
||||
// The real sessions API validates limit <= 100. Filter before paging:
|
||||
// cron/CLI traffic must not crowd IDE conversations out of the list.
|
||||
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),
|
||||
model: clip(row.model, 160), updatedAt: row.last_active || row.started_at,
|
||||
})), profile: this.backend.profile || 'default', limited: true }
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"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.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
|
||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.8",
|
||||
"version": "1.0.0-beta.9",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
|
||||
Reference in new issue
Block a user