From ff2eb473029ec8f5ca8f77a8efd4c81d9cc5819a Mon Sep 17 00:00:00 2001 From: yilsem Date: Mon, 7 Sep 2026 13:58:03 +0300 Subject: [PATCH] Fix Chrome session list API pagination in IDE beta 9 --- apps/chrome/README.md | 17 ++++- apps/chrome/tests/bridge.test.mjs | 5 ++ apps/chrome/tests/sessions-api-fixture.py | 93 +++++++++++++++++++++++ apps/chrome/tests/sessions-api.test.mjs | 57 ++++++++++++++ apps/desktop/electron/chrome-bridge.mjs | 21 ++++- apps/desktop/package.json | 2 +- package-lock.json | 2 +- 7 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 apps/chrome/tests/sessions-api-fixture.py create mode 100644 apps/chrome/tests/sessions-api.test.mjs diff --git a/apps/chrome/README.md b/apps/chrome/README.md index a7e31b9..7ff52e1 100644 --- a/apps/chrome/README.md +++ b/apps/chrome/README.md @@ -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 diff --git a/apps/chrome/tests/bridge.test.mjs b/apps/chrome/tests/bridge.test.mjs index 28543e1..3c2d161 100644 --- a/apps/chrome/tests/bridge.test.mjs +++ b/apps/chrome/tests/bridge.test.mjs @@ -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' }, diff --git a/apps/chrome/tests/sessions-api-fixture.py b/apps/chrome/tests/sessions-api-fixture.py new file mode 100644 index 0000000..34e7c06 --- /dev/null +++ b/apps/chrome/tests/sessions-api-fixture.py @@ -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() diff --git a/apps/chrome/tests/sessions-api.test.mjs b/apps/chrome/tests/sessions-api.test.mjs new file mode 100644 index 0000000..a38c984 --- /dev/null +++ b/apps/chrome/tests/sessions-api.test.mjs @@ -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() } + } +}) diff --git a/apps/desktop/electron/chrome-bridge.mjs b/apps/desktop/electron/chrome-bridge.mjs index 407bdae..6d5c917 100644 --- a/apps/desktop/electron/chrome-bridge.mjs +++ b/apps/desktop/electron/chrome-bridge.mjs @@ -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 } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 730a61a..c596895 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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": { diff --git a/package-lock.json b/package-lock.json index fda0e79..18f7b25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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",