Skip to content

Commit 9a35270

Browse files
wow-mileyclaude
andauthored
AMPR-167 #494: Ampere client-runtime readiness (MemoryStore + UpstreamLlmClient) (#495)
* AMPR-167 #494: Ampere client-runtime readiness I (Claude) implemented this in collaboration with Miley. It closes out AMPR-167 in a single commit covering Waves 0-3 plus the follow-up that wires UpstreamLlmClient through the agent construction chain. What's new (public injection surface): - MemoryStore (commonMain) — composition over KnowledgeRepository + OutcomeMemoryRepository, the seam Socket implements for on-device durable memory. - UpstreamLlmClient (commonMain) — call-origination seam in AgentLLMService; default BundledUpstreamLlmClient is byte-equivalent to pre-seam behavior. - AgentConfiguration.upstreamLlmClient + AmpereInstance.upstreamLlmClient — the load-bearing path that lets a runtime default from fromEnvironment reach AgentLLMService.call via SparkBasedAgent -> AgentReasoning. - Ampere.fromEnvironment(memoryStore, upstreamLlmClient) — additive params; existing JVM callers compile unchanged. What moved: - 7 Default*Service files from jvmMain to commonMain (zero JVM-isms in their bodies — they were misplaced). - Ampere.fromEnvironment body to commonMain; Ampere.jvm.kt trimmed to just the actual fun createInstance for the heavy Ampere.create path. Tests + CI: - MemoryStore + UpstreamLlmClient contract tests; plumbing test verifying the config field flows through AgentLLMService.call (commonTest). - fromEnvironment construction + event-bus smoke on JVM (full), iOS sim (full), Android Robolectric (construction-only — Robolectric's bundled SQLite native runtime lacks FTS5). - CI matrix: Linux + macOS run JVM + Android tests; macOS also runs iosSimulatorArm64Test. Previously :test silently skipped jvmTest in KMP modules. - Deleted 9 dormant ToolRead/Run/WriteCodeFile test stubs (no @test annotations on any platform; never ran anything). Audit doc: docs/ampr-167-fromenvironment-audit.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * AMPR-167 #494: Fix CI — use jvmTest for ampere-cli/ampere-compose I (Claude) goofed the CI task names in the previous commit. Both ampere-cli and ampere-compose are KMP modules whose runnable JVM-test task is :jvmTest, not :test (which doesn't exist in KMP modules). Also adding :ampere-compose:testDebugUnitTest for parity with ampere-core's Android coverage. Verified locally: - :ampere-cli:jvmTest passes - :ampere-compose:jvmTest passes - :ampere-compose:testDebugUnitTest passes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * AMPR-167 #494: Fix McpIntegrationTest runTest+real-dispatcher race I (Claude) fixed three flaky assertions in McpIntegrationTest that were masked when CI ran `./gradlew test` (which silently skips `:jvmTest` in KMP modules) and surfaced once my CI changes started running `jvmTest` on every PR. The race: each `runTest` test called `manager.discoverAndRegisterTools()` then `kotlinx.coroutines.delay(200)` and asserted on events captured from the event bus. But the event bus runs on `Dispatchers.Default`, and `delay(...)` inside `runTest` advances virtual time only — the real dispatcher gets zero wall-clock time, so on loaded CI runners the asserted events hadn't been delivered yet. Fix: introduced an `awaitUntil(predicate)` helper that switches to `Dispatchers.Default` (escaping the virtual scheduler) and polls the predicate up to 5s with 25ms intervals. Replaced all three fixed-delay sites with predicate polls — `2 ToolRegistered events emitted`, `ToolDiscoveryComplete event emitted`, and `tools removed from registry after disconnect`. Verified 5/5 clean local runs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * AMPR-167 #494: Bump ProgressIndicatorTest render wait from 60ms to 500ms I (Claude) fixed another pre-existing flake that my CI changes exposed once :ampere-cli:jvmTest started running on every PR. The test that failed on the ubuntu-latest job — \`state indicator can change state\` — sets the indicator's state to SUCCESS and waits 60ms before asserting the output contains "[OK]". The indicator renders on a background thread at BaseProgressIndicator.FRAME_INTERVAL_MS (50ms), so 60ms gave only ~1 frame of headroom and lost the race against JVM warmup / GC / scheduling on the CI runner. There are 18 identical Thread.sleep(60) call sites in this file, all paired with output assertions. Rather than refactor each one to a condition poll (varying assertion shapes), I extracted a single RENDER_WAIT_MS constant set to 500ms — 10x the frame interval — and replaced every site. Adds ~8s to the test run; well worth not flaking. Verified :ampere-cli:jvmTest passes locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e53774e commit 9a35270

36 files changed

Lines changed: 1568 additions & 253 deletions

.github/workflows/ci.yml

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ jobs:
1616
include:
1717
- os: ubuntu-latest
1818
java: '17'
19+
ios: false
1920
- os: macos-latest
2021
java: '21'
22+
ios: true
2123

2224
steps:
2325
- name: Checkout code
@@ -44,6 +46,11 @@ jobs:
4446
timeout-minutes: 10
4547
run: ./gradlew ktlintCheck
4648

47-
- name: Run tests
48-
timeout-minutes: 10
49-
run: ./gradlew test
49+
- name: Run JVM + Android tests
50+
timeout-minutes: 20
51+
run: ./gradlew :ampere-core:jvmTest :ampere-core:testDebugUnitTest :ampere-cli:jvmTest :ampere-compose:jvmTest :ampere-compose:testDebugUnitTest
52+
53+
- name: Run iOS simulator tests
54+
if: ${{ matrix.ios }}
55+
timeout-minutes: 25
56+
run: ./gradlew :ampere-core:iosSimulatorArm64Test

ampere-cli/src/jvmTest/kotlin/link/socket/ampere/repl/ProgressIndicatorTest.kt

Lines changed: 25 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ import kotlin.test.assertTrue
1616

1717
class ProgressIndicatorTest {
1818

19+
// Indicators render every BaseProgressIndicator.FRAME_INTERVAL_MS (50ms).
20+
// Each test waits for at least one frame to land in the output buffer
21+
// before asserting. The previous 60ms margin raced the render thread on
22+
// loaded CI runners (JVM warmup, GC, scheduling); 500ms gives ~10 frames
23+
// of headroom while keeping the suite under ~10s overall.
24+
private val RENDER_WAIT_MS: Long = 500L
25+
1926
private lateinit var outputStream: ByteArrayOutputStream
2027
private lateinit var terminal: Terminal
2128
private lateinit var writer: PrintWriter
@@ -82,7 +89,7 @@ class ProgressIndicatorTest {
8289
.build() as SpinnerIndicator
8390

8491
indicator.start()
85-
Thread.sleep(60) // Wait for at least one frame
92+
Thread.sleep(RENDER_WAIT_MS) // Wait for at least one frame
8693
indicator.stop()
8794

8895
val output = outputStream.toString()
@@ -106,7 +113,7 @@ class ProgressIndicatorTest {
106113
.build() as SpinnerIndicator
107114

108115
indicator.start()
109-
Thread.sleep(60)
116+
Thread.sleep(RENDER_WAIT_MS)
110117
indicator.stop()
111118

112119
val output = outputStream.toString()
@@ -184,7 +191,7 @@ class ProgressIndicatorTest {
184191

185192
indicator.start()
186193
indicator.update(progress = 0.5f)
187-
Thread.sleep(60) // Wait for render
194+
Thread.sleep(RENDER_WAIT_MS) // Wait for render
188195
indicator.stop()
189196

190197
val output = outputStream.toString()
@@ -202,7 +209,7 @@ class ProgressIndicatorTest {
202209

203210
indicator.start()
204211
indicator.update(progress = 1.5f) // Should clamp to 1.0
205-
Thread.sleep(60)
212+
Thread.sleep(RENDER_WAIT_MS)
206213
indicator.stop()
207214

208215
val output = outputStream.toString()
@@ -219,7 +226,7 @@ class ProgressIndicatorTest {
219226
.build()
220227

221228
indicator.start()
222-
Thread.sleep(60)
229+
Thread.sleep(RENDER_WAIT_MS)
223230
indicator.stop()
224231

225232
val output = outputStream.toString()
@@ -238,7 +245,7 @@ class ProgressIndicatorTest {
238245
.build() as StateIndicator
239246

240247
indicator.start()
241-
Thread.sleep(60)
248+
Thread.sleep(RENDER_WAIT_MS)
242249
indicator.stop()
243250

244251
val output = outputStream.toString()
@@ -256,7 +263,7 @@ class ProgressIndicatorTest {
256263

257264
indicator.start()
258265
indicator.setState(IndicatorState.SUCCESS)
259-
Thread.sleep(60)
266+
Thread.sleep(RENDER_WAIT_MS)
260267
indicator.stop()
261268

262269
val output = outputStream.toString()
@@ -303,9 +310,9 @@ class ProgressIndicatorTest {
303310
.build()
304311

305312
indicator.start()
306-
Thread.sleep(60)
313+
Thread.sleep(RENDER_WAIT_MS)
307314
indicator.update(message = "Updated")
308-
Thread.sleep(60)
315+
Thread.sleep(RENDER_WAIT_MS)
309316
indicator.stop()
310317

311318
val output = outputStream.toString()
@@ -320,7 +327,7 @@ class ProgressIndicatorTest {
320327
.build()
321328

322329
indicator.start()
323-
Thread.sleep(60)
330+
Thread.sleep(RENDER_WAIT_MS)
324331
indicator.stop()
325332

326333
val output = outputStream.toString()
@@ -336,7 +343,7 @@ class ProgressIndicatorTest {
336343
.build()
337344

338345
indicator.start()
339-
Thread.sleep(60)
346+
Thread.sleep(RENDER_WAIT_MS)
340347
indicator.stop()
341348

342349
val output = outputStream.toString()
@@ -348,7 +355,7 @@ class ProgressIndicatorTest {
348355
fun `SimpleSpinner provides backward compatibility`() {
349356
val spinner = SimpleSpinner(terminal)
350357
spinner.start("Loading...")
351-
Thread.sleep(60)
358+
Thread.sleep(RENDER_WAIT_MS)
352359
spinner.stop()
353360

354361
val output = outputStream.toString()
@@ -405,7 +412,7 @@ class ProgressIndicatorTest {
405412

406413
indicator.setState(IndicatorState.THINKING)
407414
indicator.start()
408-
Thread.sleep(60)
415+
Thread.sleep(RENDER_WAIT_MS)
409416
indicator.stop()
410417

411418
val output = outputStream.toString()
@@ -423,7 +430,7 @@ class ProgressIndicatorTest {
423430

424431
indicator.start()
425432
indicator.update(progress = 0.5f)
426-
Thread.sleep(60)
433+
Thread.sleep(RENDER_WAIT_MS)
427434
indicator.complete(success = true)
428435

429436
val output = outputStream.toString()
@@ -445,7 +452,7 @@ class ProgressIndicatorTest {
445452

446453
indicator.start()
447454
indicator.update(progress = 0.5f)
448-
Thread.sleep(60)
455+
Thread.sleep(RENDER_WAIT_MS)
449456
indicator.stop()
450457

451458
val output = outputStream.toString()
@@ -468,7 +475,7 @@ class ProgressIndicatorTest {
468475

469476
indicator.start()
470477
indicator.update(progress = 0.25f)
471-
Thread.sleep(60)
478+
Thread.sleep(RENDER_WAIT_MS)
472479
indicator.stop()
473480

474481
val wideOutput = outputStream.toString()
@@ -479,7 +486,7 @@ class ProgressIndicatorTest {
479486
terminal.setSize(Size(30, 24))
480487
indicator.start()
481488
indicator.update(progress = 0.25f)
482-
Thread.sleep(60)
489+
Thread.sleep(RENDER_WAIT_MS)
483490
indicator.stop()
484491

485492
val narrowOutput = outputStream.toString()
@@ -497,7 +504,7 @@ class ProgressIndicatorTest {
497504
.build()
498505

499506
indicator.start()
500-
Thread.sleep(60)
507+
Thread.sleep(RENDER_WAIT_MS)
501508
indicator.stop()
502509

503510
val output = outputStream.toString()

ampere-core/build.gradle.kts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,15 @@ kotlin {
195195
implementation("com.charleskorn.kaml:kaml:0.72.0")
196196
}
197197
}
198+
val androidUnitTest by getting {
199+
dependencies {
200+
implementation(kotlin("test"))
201+
implementation("org.robolectric:robolectric:4.14")
202+
implementation("androidx.test:core:1.6.1")
203+
implementation("androidx.test.ext:junit:1.2.1")
204+
implementation("junit:junit:4.13.2")
205+
}
206+
}
198207
val jsMain by getting {
199208
dependencies {
200209
implementation("io.ktor:ktor-client-js:3.2.2")
@@ -250,6 +259,11 @@ android {
250259
kotlin {
251260
jvmToolchain(21)
252261
}
262+
testOptions {
263+
unitTests {
264+
isIncludeAndroidResources = true
265+
}
266+
}
253267
}
254268

255269
val kotlinConfiguration = tasks.register("kotlinConfiguration") {

ampere-core/src/androidUnitTest/kotlin/link/socket/ampere/agents/execution/tools/ToolReadCodebaseTest.android.kt

Lines changed: 0 additions & 19 deletions
This file was deleted.

ampere-core/src/androidUnitTest/kotlin/link/socket/ampere/agents/execution/tools/ToolRunTestsTest.android.kt

Lines changed: 0 additions & 16 deletions
This file was deleted.

ampere-core/src/androidUnitTest/kotlin/link/socket/ampere/agents/execution/tools/ToolWriteCodeFileTest.android.kt

Lines changed: 0 additions & 13 deletions
This file was deleted.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package link.socket.ampere.api
2+
3+
import android.content.Context
4+
import androidx.test.core.app.ApplicationProvider
5+
import app.cash.sqldelight.db.SqlDriver
6+
import kotlin.test.AfterTest
7+
import kotlin.test.BeforeTest
8+
import kotlin.test.assertNotNull
9+
import kotlinx.coroutines.ExperimentalCoroutinesApi
10+
import kotlinx.coroutines.test.TestScope
11+
import kotlinx.coroutines.test.UnconfinedTestDispatcher
12+
import link.socket.ampere.agents.domain.knowledge.KnowledgeRepository
13+
import link.socket.ampere.agents.domain.knowledge.KnowledgeRepositoryImpl
14+
import link.socket.ampere.agents.environment.EnvironmentService
15+
import link.socket.ampere.data.createAndroidDriver
16+
import link.socket.ampere.db.Database
17+
import org.junit.Test
18+
import org.junit.runner.RunWith
19+
import org.robolectric.RobolectricTestRunner
20+
import org.robolectric.annotation.Config
21+
22+
/**
23+
* Android construction smoke test for [Ampere.fromEnvironment].
24+
*
25+
* Runs as a Robolectric unit test so it executes on the CI JVM without
26+
* requiring an emulator. Exercises [createAndroidDriver] —
27+
* [AndroidSqliteDriver][app.cash.sqldelight.driver.android.AndroidSqliteDriver]
28+
* end-to-end — proving that the migrated `fromEnvironment` extension and
29+
* its `Default*Service` dependencies compile and execute on Android.
30+
*/
31+
@OptIn(ExperimentalCoroutinesApi::class)
32+
@RunWith(RobolectricTestRunner::class)
33+
@Config(sdk = [33])
34+
class AmpereFromEnvironmentAndroidTest {
35+
36+
private val scope = TestScope(UnconfinedTestDispatcher())
37+
38+
private lateinit var driver: SqlDriver
39+
private lateinit var database: Database
40+
private lateinit var environmentService: EnvironmentService
41+
private lateinit var knowledgeRepository: KnowledgeRepository
42+
43+
@BeforeTest
44+
fun setUp() {
45+
val context: Context = ApplicationProvider.getApplicationContext()
46+
driver = createAndroidDriver(context = context, dbName = "ampere-android-test.db")
47+
database = Database(driver)
48+
environmentService = EnvironmentService.create(database = database, scope = scope)
49+
knowledgeRepository = KnowledgeRepositoryImpl(database)
50+
}
51+
52+
@AfterTest
53+
fun tearDown() {
54+
driver.close()
55+
}
56+
57+
@Test
58+
fun `fromEnvironment constructs on Android with AndroidSqliteDriver`() {
59+
// Construction-only smoke: AndroidSqliteDriver is lazily initialized,
60+
// so this proves the migrated `fromEnvironment` + `Default*Service`
61+
// graph wires up under Android. The full event-bus smoke (pursue ->
62+
// observe TaskCreated) lives in the JVM and iOS suites; Robolectric's
63+
// bundled SQLite native runtime lacks FTS5, which Ampere's knowledge
64+
// schema requires. A proper Android instrumented test (real device /
65+
// emulator with full SQLite) is tracked as a follow-up.
66+
val instance = Ampere.fromEnvironment(
67+
environmentService = environmentService,
68+
knowledgeRepository = knowledgeRepository,
69+
)
70+
71+
assertNotNull(instance.agents)
72+
assertNotNull(instance.tickets)
73+
assertNotNull(instance.threads)
74+
assertNotNull(instance.events)
75+
assertNotNull(instance.outcomes)
76+
assertNotNull(instance.pricing)
77+
assertNotNull(instance.knowledge)
78+
assertNotNull(instance.status)
79+
80+
instance.close()
81+
}
82+
}

ampere-core/src/commonMain/kotlin/link/socket/ampere/agents/config/AgentConfiguration.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import link.socket.ampere.agents.domain.routing.CognitiveRelay
66
import link.socket.ampere.domain.agent.bundled.AgentDefinition
77
import link.socket.ampere.domain.ai.configuration.AIConfiguration
88
import link.socket.ampere.domain.llm.LlmProvider
9+
import link.socket.ampere.llm.BundledUpstreamLlmClient
10+
import link.socket.ampere.llm.UpstreamLlmClient
911

1012
@Serializable
1113
data class AgentConfiguration(
@@ -16,4 +18,14 @@ data class AgentConfiguration(
1618
val llmProvider: LlmProvider? = null,
1719
@Transient
1820
val cognitiveRelay: CognitiveRelay? = null,
21+
/**
22+
* Outbound LLM-call seam. Defaults to [BundledUpstreamLlmClient] (the
23+
* pre-seam direct-call behavior). Embedded consumers (e.g. Socket)
24+
* override this to route LLM calls through their backend proxy.
25+
*
26+
* Note: a non-null [llmProvider] still short-circuits before the
27+
* upstream client runs.
28+
*/
29+
@Transient
30+
val upstreamLlmClient: UpstreamLlmClient = BundledUpstreamLlmClient,
1931
)

0 commit comments

Comments
 (0)