Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
"models-dev:update": "node scripts/update-models-dev-snapshot.mjs",
"check:build-prereqs": "node scripts/check-build-prereqs.mjs",
"harmony:architecture": "node scripts/check-harmonyos-architecture.mjs",
"mobile:architecture": "node scripts/check-mobile-architecture.mjs",
"mobile:architecture": "node scripts/check-mobile-architecture.mjs && node --test scripts/mobile-client-build.contract.test.mjs",
"mobile:ui:generate": "node scripts/mobile-ui-design-system.mjs",
"mobile:ui:check": "node scripts/mobile-ui-design-system.mjs --check && node scripts/mobile-ui-preview.mjs --check",
"mobile:ui:preview": "node scripts/mobile-ui-design-system.mjs && node scripts/mobile-ui-preview.mjs",
Expand Down
41 changes: 41 additions & 0 deletions scripts/mobile-client-build.contract.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';

// Cross-platform source contracts belong to the repository, not a shared
// runtime module that would then need to reach into a platform's implementation.
const root = path.resolve(import.meta.dirname, '..');
const source = relative => fs.readFileSync(path.join(root, relative), 'utf8');
const services = 'src/apps/mobile/harmonyos/entry/src/main/ets/services';

function protocolVersion(file, declaration) {
const match = source(file).match(declaration);
assert.ok(match, `Missing protocol version declaration in ${file}`);
return Number(match[1]);
}

test('mobile and web protocol versions match the Rust account contract', () => {
const expected = protocolVersion(
'src/crates/contracts/product-domains/src/account.rs',
/^pub const CLIENT_PROTOCOL_VERSION: u32 = (\d+);$/m,
);
for (const [file, declaration] of [
['src/apps/mobile/shared/core-transport/src/commonMain/kotlin/com/openbitfun/mobile/core/transport/ClientBuild.kt',
/^public const val CLIENT_PROTOCOL_VERSION: Int = (\d+)$/m],
[`${services}/ClientBuild.ets`, /^export const CLIENT_PROTOCOL_VERSION: number = (\d+);$/m],
['src/shared/relay-transport/ClientBuild.ts', /^export const CLIENT_PROTOCOL_VERSION = (\d+);$/m],
]) {
assert.equal(protocolVersion(file, declaration), expected, `${file} must match the Rust contract`);
}
});

test('HarmonyOS reports its bundle build and protocol on login and every handshake', () => {
// Preserve the transport assertions formerly owned by the shared JVM suite.
// The version string is resolved from this platform's bundle at runtime.
const realtime = source(`${services}/AccountRealtime.ets`);
assert.ok(realtime.includes('clientVersion: clientVersion()'));
assert.ok(realtime.includes('clientProtocol: CLIENT_PROTOCOL_VERSION'));
const client = source(`${services}/CloudAccountClient.ets`);
assert.ok(client.includes('clientVersion: clientVersion(), clientProtocol: CLIENT_PROTOCOL_VERSION'));
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { bundleManager } from '@kit.AbilityKit';
* `openbitfun_product_domains::account::CLIENT_PROTOCOL_VERSION` in
* `src/crates/contracts/product-domains/src/account.rs` and to the Kotlin
* `CLIENT_PROTOCOL_VERSION` the other mobile clients share;
* `ClientBuildContractTest` in `shared/core-transport` pins them together so
* `scripts/mobile-client-build.contract.test.mjs` pins them together so
* one side can never drift.
*/
export const CLIENT_PROTOCOL_VERSION: number = 2;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
import { describe, it, expect } from '@ohos/hypium';
import { CloudAccountCrypto } from '../../../main/ets/services/CloudAccountCrypto';
import openbitfunCrypto from 'libopenbitfun_crypto.so';
import { Encoding } from '../../../main/ets/services/Encoding';

// The account password login that wrapped this binding is retired, so the load
// goes straight to the native Argon2id work the library still ships.
const LOAD_MEMORY_KIB: number = 16 * 1024;
const LOAD_PASSES: number = 2;
const LOAD_LANES: number = 1;

function nativeArgon2id(input: Uint8Array, salt: Uint8Array): Promise<Uint8Array> {
return openbitfunCrypto.argon2idRaw(input, salt, LOAD_MEMORY_KIB, LOAD_PASSES, LOAD_LANES);
}

export default function nativeCryptoLoadTest() {
describe('NativeCryptoLoad', () => {
it('keeps the ArkTS event loop responsive during concurrent Argon2id work', 0, async () => {
const salt = new Uint8Array([0x42, 0x69, 0x74, 0x46, 0x75, 0x6e, 0x2d, 0x31,
0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39]);
const input = Encoding.utf8ToBytes('openbitfun-device-load');
let heartbeatTicks = 0;
const heartbeatId = setInterval(() => {
heartbeatTicks += 1;
}, 5);
const startedAt = Date.now();

const results = await Promise.all([
CloudAccountCrypto.derivePasswordHash('openbitfun-device-load', salt, { m: 16 * 1024, t: 2, p: 1 }),
CloudAccountCrypto.derivePasswordHash('openbitfun-device-load', salt, { m: 16 * 1024, t: 2, p: 1 }),
CloudAccountCrypto.derivePasswordHash('openbitfun-device-load', salt, { m: 16 * 1024, t: 2, p: 1 })
nativeArgon2id(input, salt),
nativeArgon2id(input, salt),
nativeArgon2id(input, salt)
]);
clearInterval(heartbeatId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,8 @@ import {
} from '../main/ets/services/CloudAccountClient';
import {
CloudAccountSessionStore,
PersistedCloudAccountSession
PersistedCloudAccountSession,
PersistedWatchProvisionIdentity
} from '../main/ets/services/CloudAccountSessionStore';
import { RemotePageState } from '../main/ets/pages/state/RemotePageState';
import { ConversationViewState } from '../main/ets/pages/state/ConversationViewState';
Expand Down Expand Up @@ -427,6 +428,20 @@ class GatedAccountSessionManager extends ScriptedAccountSessionManager {

/** HUKS and preferences are not reachable from a local unit run. */
class InMemoryCloudAccountSessionStore extends CloudAccountSessionStore {
private watchIdentity?: PersistedWatchProvisionIdentity;
async loadWatchProvisionIdentity(relayUrl: string, userId: string,
deviceId: string): Promise<PersistedWatchProvisionIdentity | undefined> {
// A persisted key keeps this refusal test independent of HUKS/randomness
// (neither platform service exists in the host LocalTest runner).
return this.watchIdentity || {
relayUrl, userId, deviceId, requestId: '12345678-1234-4234-8234-123456789abc',
privateKeyBase64: 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='
};
}
async saveWatchProvisionIdentity(identity: PersistedWatchProvisionIdentity): Promise<void> {
this.watchIdentity = identity;
}

readonly saved: PersistedCloudAccountSession[] = [];
profile?: GitHubAccountProfile;
async loadProfile(_userId: string): Promise<GitHubAccountProfile | undefined> { return this.profile; }
Expand Down Expand Up @@ -1319,7 +1334,8 @@ export default function transportAndGeneralChatUnitTest() {
'12345678-1234-4234-8234-123456789abc'
);
} catch (err) {
if (err instanceof CloudAccountRequestError) statusCode = err.statusCode;
if (!(err instanceof CloudAccountRequestError)) throw new Error(String(err));
statusCode = err.statusCode;
}
expect(statusCode).assertEqual(expectedStatus);
expect(harness.sessionStore.clears).assertEqual(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const models = load('../model/InteractionMailbox');
const { InteractionMailboxStore } = load('InteractionMailboxStore', { '../model/InteractionMailbox': models });
const reducer = load('DurableSessionReducer');
const { ChatSessionController } = load('ChatSessionController', {
'./MessageQueueStore': load('MessageQueueStore', { '../model/MessageQueue': load('../model/MessageQueue') }),
'./InteractionMailboxStore': { InteractionMailboxStore }, './DurableSessionReducer': reducer,
'./SubagentStreamCoordinator': load('SubagentStreamCoordinator', { './DurableSessionReducer': reducer }),
});
Expand All @@ -25,6 +26,7 @@ const snapshot = (requests = [request()], questions = [], sessionId = 's') => ({
function fixture() {
const f = { state: null, wire: snapshot(), calls: [], fail: false };
f.client = {
supportsMessageQueue: () => false,
async getModelCatalog() { return { version: 1, models: [], default_models: {} }; },
async hostInvoke(command, args) {
f.calls.push({ command, args });
Expand Down Expand Up @@ -67,6 +69,7 @@ test('tool completion cannot consume runtime approval or manufacture an active t
assert.deepEqual(mailbox.requests.map(r => r.requestId), ['approval']);
assert.equal(transcript.activeTurn, undefined);
assert.deepEqual(transcript.messageSnapshot, []);
controller.stop();
f.wire = snapshot([]);
await callbacks.onEvent({ session_id: 's', event: 'session-interaction-changed', payload: {} });
assert.deepEqual(mailbox.requests, []);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,21 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
function load(name, dependencies = {}) {
const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets', `${name}.ets`), 'utf8');
const exports = {};
new Function('require', 'exports', ts.transpileModule(source, { compilerOptions: {
target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS,
} }).outputText)(key => dependencies[key] || {}, exports);
return exports;
}
const queueModule = load('services/MessageQueueStore', { '../model/MessageQueue': load('model/MessageQueue') });
const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/services/ChatSessionController.ets'), 'utf8');
const exported = {};
new Function('require', 'exports', ts.transpileModule(source, {compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS}}).outputText)(name => ({
'./MessageQueueStore': queueModule,
'./InteractionMailboxStore': { InteractionMailboxStore: class { select() {} } },
'./DurableSessionReducer': { DurableSessionReducer: class { messages() { return []; } } },
'./DurableSessionReducer': load('services/DurableSessionReducer'),
'./SubagentStreamCoordinator': { SubagentStreamCoordinator: class {
beginSession() {}
endSession() {}
Expand All @@ -20,6 +30,7 @@ const flush = () => new Promise(resolve => setImmediate(resolve));
function fixture() {
const requests = [], streams = [], snapshots = [], errors = [];
const manager = {
supportsMessageQueue: () => false,
getModelCatalog(sessionId) { return new Promise((resolve, reject) => requests.push({sessionId, resolve, reject})); },
subscribeSession(sessionId, callbacks) { streams.push(callbacks); return {close() {}, isClosed() {return false;}}; },
};
Expand Down
25 changes: 19 additions & 6 deletions src/apps/mobile/harmonyos/tools/tests/session-record.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ts = require('typescript');
const activeControllers = new Set();
test.afterEach(() => {
for (const controller of activeControllers) controller.stop();
activeControllers.clear();
});
function createController(Controller, manager, hooks) {
// These transcript fixtures model hosts without a message queue. Leaving
// the capability method absent starts error/retry timers in the real store.
const controller = new Controller(Object.assign(manager, { supportsMessageQueue: () => false }), hooks);
activeControllers.add(controller);
return controller;
}
function load(name, dependencies = {}) {
const source = fs.readFileSync(path.join(__dirname, '../../entry/src/main/ets/services', `${name}.ets`), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS } }).outputText;
Expand All @@ -18,6 +30,7 @@ const { DurableSessionReducer } = reducerModule;
const subagentModule = load('SubagentStreamCoordinator', { './DurableSessionReducer': reducerModule });
function loadController() {
return load('ChatSessionController', {
'./MessageQueueStore': load('MessageQueueStore', { '../model/MessageQueue': load('../model/MessageQueue') }),
'./DurableSessionReducer': reducerModule, './InteractionMailboxStore': mailboxModule,
'./SubagentStreamCoordinator': subagentModule
});
Expand All @@ -33,7 +46,7 @@ function suggestionFixture() {
const snapshots = [];
const manager = { getModelCatalog: async () => ({version: 1, models: [], default_models: {}}),
subscribeSession: (_id, next) => { callbacks = next; return {wake() {}, close() {}}; } };
const controller = new ChatSessionController(manager, {onSnapshot: value => snapshots.push(value), canPoll: () => true, onError: error => {throw error;}});
const controller = createController(ChatSessionController, Object.assign(manager, { supportsMessageQueue: () => false }), {onSnapshot: value => snapshots.push(value), canPoll: () => true, onError: error => {throw error;}});
controller.start('session', {pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0});
return { controller, snapshots, stream: () => callbacks, latest: () => snapshots.at(-1) };
}
Expand Down Expand Up @@ -126,7 +139,7 @@ test('controller hydrates from the same record log without transcript RPC', asyn
const { ChatSessionController } = loadController();
let callbacks, snapshots = [];
const manager = { getModelCatalog: async () => ({ version: 1, models: [], default_models: {} }), subscribeSession: (_id, next) => { callbacks = next; return { wake() {}, close() {} }; }, getSessionMessages: () => { throw new Error('Snapshot RPC is forbidden'); } };
const controller = new ChatSessionController(manager, { onSnapshot: value => snapshots.push(value), canPoll: () => true, onError: error => { throw error; } });
const controller = createController(ChatSessionController, Object.assign(manager, { supportsMessageQueue: () => false }), { onSnapshot: value => snapshots.push(value), canPoll: () => true, onError: error => { throw error; } });
controller.start('session', { pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0 });
await callbacks.onEvent(record(1, 'partial'));
assert.equal(snapshots.length, 0);
Expand Down Expand Up @@ -180,7 +193,7 @@ test('initial and resumed mailbox restores questions independently of transcript
return {sessionId:'session',permissions:{revision:0,requests:[]},userQuestions:{revision:1,questions:[{toolId:'question-tool',sessionId:'session',questions:question,registeredAtMs:1}]}};
}
};
const controller = new ChatSessionController(manager,{onSnapshot:value=>snapshot=value,onMailbox:value=>mailbox=value,onError:error=>{throw error;},canPoll:()=>true});
const controller = createController(ChatSessionController, Object.assign(manager, { supportsMessageQueue: () => false }),{onSnapshot:value=>snapshot=value,onMailbox:value=>mailbox=value,onError:error=>{throw error;},canPoll:()=>true});
controller.start('session',{pollVersion:0,knownMessageCount:0,knownModelCatalogVersion:0});
await callbacks.onResumed(); await callbacks.onCaughtUp();
assert.equal(snapshot.activeTurn,undefined);
Expand Down Expand Up @@ -321,7 +334,7 @@ test('history publishes user and assistant together after reduction, including a
getModelCatalog: () => new Promise(resolve => { resolveCatalog = resolve; }),
subscribeSession: (_id, next) => { callbacks = next; return { close() {} }; }
};
const controller = new ChatSessionController(manager, { canPoll: () => true,
const controller = createController(ChatSessionController, Object.assign(manager, { supportsMessageQueue: () => false }), { canPoll: () => true,
onSnapshot: value => snapshots.push(value), onError: error => { throw error; } });
controller.start('session', { pollVersion: 0, knownMessageCount: 0, knownModelCatalogVersion: 0 });
await callbacks.onCaughtUp();
Expand Down Expand Up @@ -355,7 +368,7 @@ async function durableTimelineHarness() {
timeline.reset('session');
let stream;
const { ChatSessionController } = loadController();
const controller = new ChatSessionController({
const controller = createController(ChatSessionController, {
getModelCatalog: async () => ({ version: 1, models: [], default_models: {} }),
subscribeSession: (_id, callbacks) => { stream = callbacks; return { wake() {}, close() {} }; }
}, { onSnapshot: snapshot => timeline.applySnapshot(snapshot), canPoll: () => true,
Expand Down Expand Up @@ -455,7 +468,7 @@ test('host restart terminal record settles active turn without reporting success
const { ChatSessionController } = loadController();
let stream, snapshot;
const timeline = new ChatTimelineStore(); timeline.reset('session');
const controller = new ChatSessionController({ getModelCatalog: async () => ({version:1,models:[],default_models:{}}),
const controller = createController(ChatSessionController, { getModelCatalog: async () => ({version:1,models:[],default_models:{}}),
subscribeSession: (_id, callbacks) => { stream=callbacks; return {wake(){},close(){}}; }
}, { onSnapshot: value => { snapshot=value; timeline.applySnapshot(value); }, canPoll:()=>true, onError:error=>{throw error;} });
controller.start('session',{pollVersion:0,knownMessageCount:0,knownModelCatalogVersion:0});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ test('sidebar final entry hierarchy keeps modes in workspace plus and tools in t
assert.doesNotMatch(menu,/supportsHarnessProfiles|workspaceTools\.files|workspaceTools\.terminal|openWorkspaceTools/);
const sidebar=fs.readFileSync(path.join(components,'AppSidebar.ets'),'utf8');
assert.doesNotMatch(sidebar,/requestChat|sidebar\.newChat|sidebar\.conversations/);
assert.match(sidebar,/workspaceTools\.title/);
const footer=fs.readFileSync(path.join(components,'SidebarToolsFooter.ets'),'utf8');
assert.match(sidebar,/SidebarToolsFooter\(\{/);
assert.match(footer,/workspaceTools\.title/);
assert.match(sidebar,/if \(this.connectionState !== 'connected'\) return/);
assert.doesNotMatch(sidebar,/DeviceToolsPicker|showToolsPicker/);
assert.match(sidebar,/type: 'open-device', deviceId: this.controlTargetDeviceId/);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,26 @@ public data class RemoteSession public constructor(
public val workspaceName: String?,
public val workspaceIdentity: RemoteWorkspaceIdentity?,
/** Set when the session hangs off another one (btw/review/miniapp/subagent). */
public val parentSessionId: String? = null,
public val relationshipKind: String? = null,
public val parentSessionId: String?,
public val relationshipKind: String?,
) {
// Explicit overloads keep legacy callers working on both Kotlin and Swift.
// Kotlin default arguments are not exported as Swift initializers.
public constructor(id: String, title: String, agentType: String, status: String, updatedAt: String,
createdAt: String, messageCount: Int, workspacePath: String?, workspaceName: String?) :
this(id, title, agentType, status, updatedAt, createdAt, messageCount, workspacePath, workspaceName, null)

public constructor(id: String, title: String, agentType: String, status: String, updatedAt: String,
createdAt: String, messageCount: Int, workspacePath: String?, workspaceName: String?,
workspaceIdentity: RemoteWorkspaceIdentity?) :
this(id, title, agentType, status, updatedAt, createdAt, messageCount, workspacePath, workspaceName,
workspaceIdentity, null, null)

public constructor(id: String, title: String, agentType: String, status: String, updatedAt: String,
createdAt: String, messageCount: Int, workspacePath: String?, workspaceName: String?,
workspaceIdentity: RemoteWorkspaceIdentity?, parentSessionId: String?) :
this(id, title, agentType, status, updatedAt, createdAt, messageCount, workspacePath, workspaceName,
workspaceIdentity, parentSessionId, null)
}

public data class ChatSessionCursor public constructor(
Expand Down
Loading
Loading