Workbench Phase 1: Assistant Foundation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Generalize the assistant session foundation so the Workflow Workbench can reuse it. This phase
adds template capabilities in place of the hard-coded axiom-config-assistant checks, a persisted
conversation history journal, and per-session MCP tokens.
Architecture:
- A SessionCapability enum is declared by built-in template JSON. It flows through the SessionTemplate
record, the AssistantSession, and the REST beans, and every hard-coded template-ID check uses it instead.
- A HistoryJournal listener appends every session event to history.jsonl in the session directory.
- A random per-session token is handed to the @axiom-assistant MCP server, sent as
X-Axiom-Session-Token, and required by the only MCP-only endpoint, /assistant/sessions/{id}/validate.
Tech Stack: Java 25, Quarkus 3.40.1, Jackson, JUnit 5, RestAssured, React 19 + TypeScript, Vitest.
Spec: docs/superpowers/specs/2026-10-08-workflow-workbench-design.md, "§1 Architecture → Shared
assistant foundation" and "§6 Phasing" item 1.
Global Constraints
- REST changes start in
common/api/src/main/resources/openapi.json. Regenerate withmvn -q -pl common/api install -DskipTests. Never add@Pathto*ResourceImplclasses, and use the generated beans. - Java style: Javadoc on public methods, 4-space indent, explicit types (no ambiguous
var), JUnit 5. - Markdown wraps at 110 columns.
- Do not run
./build.shormvn clean: the user may havequarkus:devrunning. Use the focused commandmvn -q -pl app -am test -Dtest=<TestClass> -Dsurefire.failIfNoSpecifiedTests=false. rgis not installed. Usegit grep.- Commit with conventional commit messages and no AI attribution. Do not push.
- The Configuration Assistant's behaviour must not change. The only expected change to an existing test is
that
AssistantValidateEndpointTestsends the token header (Task 4). - Capability JSON names:
config-items,validation-feedback,composition. - Token header name:
X-Axiom-Session-Token. Token environment variable for MCP servers:AXIOM_SESSION_TOKEN. - History file:
<sessionDir>/history.jsonl, one JSON object{"type": ..., "data": ...}per line.
File Map
| File | Change |
|---|---|
app/src/main/java/io/apitomy/axiom/app/assistant/SessionCapability.java |
Create: capability enum |
app/src/main/java/io/apitomy/axiom/app/assistant/SessionTemplateService.java |
Add capabilities to the record and parse it |
app/src/main/resources/templates/assistant-templates/axiom-config-assistant.json |
Declare capabilities |
app/src/main/java/io/apitomy/axiom/app/assistant/AssistantSession.java |
Carry capabilities and the MCP token |
app/src/main/java/io/apitomy/axiom/app/assistant/AssistantSessionManager.java |
Use capabilities, attach the journal, generate the token |
app/src/main/java/io/apitomy/axiom/app/assistant/AssistantContextBuilder.java |
Use capabilities for the working-directory subfolders |
app/src/main/java/io/apitomy/axiom/app/assistant/HistoryJournal.java |
Create: JSONL journal |
app/src/main/java/io/apitomy/axiom/app/rest/AssistantResourceImpl.java |
Capability checks, beans, token check |
common/api/src/main/resources/openapi.json |
capabilities on SessionTemplate and AssistantSessionInfo; document the token |
app/src/main/resources/templates/axiom-assistant-mcp/server.js |
Send the token header |
ui/src/config/capabilities.ts (+ .test.ts) |
Create: hasCapability helper |
ui/src/config/api.ts, ui/src/pages/AssistantSessionPage.tsx, ui/src/pages/SessionTemplatesPage.tsx |
Use capabilities |
Tests in app/src/test/java/io/apitomy/axiom/app/assistant/ |
New and updated tests |
Task 1: SessionCapability and template capabilities
Files:
- Create: app/src/main/java/io/apitomy/axiom/app/assistant/SessionCapability.java
- Modify: app/src/main/java/io/apitomy/axiom/app/assistant/SessionTemplateService.java (record at lines
61-77, toTemplate at 234-251, parseTemplateJson at 253-272)
- Modify: app/src/main/java/io/apitomy/axiom/app/rest/AssistantResourceImpl.java (fromTemplateBean at
712-730)
- Modify: app/src/main/resources/templates/assistant-templates/axiom-config-assistant.json
- Test: app/src/test/java/io/apitomy/axiom/app/assistant/SessionCapabilityTest.java
Interfaces:
- Produces:
- enum SessionCapability { CONFIG_ITEMS, VALIDATION_FEEDBACK, COMPOSITION }, with String jsonName(),
static Optional<SessionCapability> fromJsonName(String) and
static Set<SessionCapability> parse(JsonNode array).
- A new last component on SessionTemplateService.SessionTemplate:
Set<SessionCapability> capabilities, placed after boolean builtIn.
- User-defined (database) templates always have an empty set.
- [ ] Step 1: Write the failing test
package io.apitomy.axiom.app.assistant;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.io.InputStream;
import java.util.EnumSet;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies capability parsing and the capabilities declared by the built-in templates.
*/
class SessionCapabilityTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Test
void parsesKnownNamesAndIgnoresUnknown() throws IOException {
JsonNode array = MAPPER.readTree("[\"config-items\", \"composition\", \"bogus\"]");
Set<SessionCapability> parsed = SessionCapability.parse(array);
assertEquals(EnumSet.of(SessionCapability.CONFIG_ITEMS, SessionCapability.COMPOSITION), parsed);
}
@Test
void missingArrayParsesToEmptySet() {
assertTrue(SessionCapability.parse(MAPPER.missingNode()).isEmpty());
}
@Test
void jsonNamesRoundTrip() {
for (SessionCapability capability : SessionCapability.values()) {
assertEquals(Optional.of(capability), SessionCapability.fromJsonName(capability.jsonName()));
}
assertEquals("validation-feedback", SessionCapability.VALIDATION_FEEDBACK.jsonName());
}
@Test
void configAssistantDeclaresItemsAndFeedback() throws IOException {
assertEquals(EnumSet.of(SessionCapability.CONFIG_ITEMS, SessionCapability.VALIDATION_FEEDBACK),
capabilitiesOf("axiom-config-assistant.json"));
}
@Test
void otherBuiltInsDeclareNothing() throws IOException {
assertTrue(capabilitiesOf("general-assistant.json").isEmpty());
assertTrue(capabilitiesOf("subject-assistant.json").isEmpty());
}
private static Set<SessionCapability> capabilitiesOf(String file) throws IOException {
try (InputStream is = SessionCapabilityTest.class.getResourceAsStream(
"/templates/assistant-templates/" + file)) {
return SessionCapability.parse(MAPPER.readTree(is).path("capabilities"));
}
}
}
- [ ] Step 2: Run the test and confirm it fails
Run: mvn -q -pl app -am test -Dtest=SessionCapabilityTest -Dsurefire.failIfNoSpecifiedTests=false
Expected: a compilation failure, because SessionCapability does not exist.
- [ ] Step 3: Create the enum
package io.apitomy.axiom.app.assistant;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Optional;
import java.util.Set;
/**
* Optional behaviours a session template enables. Code checks capabilities instead of template IDs, so new
* built-in templates (such as the workbench) can opt in to shared behaviour.
*/
public enum SessionCapability {
/** File-based configuration items in the working directory, with the items, validate and apply endpoints. */
CONFIG_ITEMS("config-items"),
/** Re-validate working-directory items after each tool result and feed problems back to the agent. */
VALIDATION_FEEDBACK("validation-feedback"),
/** The session is bound to a workbench composition. */
COMPOSITION("composition");
private final String jsonName;
SessionCapability(String jsonName) {
this.jsonName = jsonName;
}
/**
* Returns the name used in template JSON and REST payloads.
*
* @return the JSON name, for example {@code config-items}
*/
public String jsonName() {
return jsonName;
}
/**
* Looks up a capability by its JSON name.
*
* @param name the JSON name
* @return the capability, or empty if the name is unknown
*/
public static Optional<SessionCapability> fromJsonName(String name) {
return Arrays.stream(values()).filter(c -> c.jsonName.equals(name)).findFirst();
}
/**
* Parses a JSON array of capability names. Unknown names are ignored, and a missing or non-array node gives
* an empty set.
*
* @param array the JSON node, typically a template's {@code capabilities} field
* @return an unmodifiable set of capabilities
*/
public static Set<SessionCapability> parse(JsonNode array) {
if (array == null || !array.isArray()) {
return Collections.emptySet();
}
EnumSet<SessionCapability> result = EnumSet.noneOf(SessionCapability.class);
array.forEach(n -> fromJsonName(n.asText()).ifPresent(result::add));
return Collections.unmodifiableSet(result);
}
}
- [ ] Step 4: Add
capabilitiesto the template record and its constructors
In SessionTemplateService.java:
1. Add java.util.Set to the imports.
2. Append the component Set<SessionCapability> capabilities after boolean builtIn in the record, and
add @param capabilities behaviours this template enables (empty for user-defined templates) to its
Javadoc.
3. In toTemplate(...), pass Set.of() as the new last argument after false.
4. In parseTemplateJson(...), pass SessionCapability.parse(node.path("capabilities")) after builtIn.
In AssistantResourceImpl.fromTemplateBean(...), pass java.util.Set.of() after the final false
argument. User templates cannot declare capabilities.
- [ ] Step 5: Declare the capabilities in the config-assistant template
In axiom-config-assistant.json, add this line directly after the "description": ..., line:
- [ ] Step 6: Run the test and confirm it passes
Run: mvn -q -pl app -am test -Dtest=SessionCapabilityTest -Dsurefire.failIfNoSpecifiedTests=false
Expected: PASS (5 tests).
- [ ] Step 7: Commit
git add app/src/main/java/io/apitomy/axiom/app/assistant/SessionCapability.java \
app/src/main/java/io/apitomy/axiom/app/assistant/SessionTemplateService.java \
app/src/main/java/io/apitomy/axiom/app/rest/AssistantResourceImpl.java \
app/src/main/resources/templates/assistant-templates/axiom-config-assistant.json \
app/src/test/java/io/apitomy/axiom/app/assistant/SessionCapabilityTest.java
git commit -m "feat(assistant): add session template capabilities"
Task 2: Replace template-ID checks with capability checks (backend and REST)
Files:
- Modify: app/src/main/java/io/apitomy/axiom/app/assistant/AssistantSession.java
- Modify: app/src/main/java/io/apitomy/axiom/app/assistant/AssistantSessionManager.java (lines ~209-212,
~267-268, ~293-295)
- Modify: app/src/main/java/io/apitomy/axiom/app/assistant/AssistantContextBuilder.java (lines 61-87)
- Modify: app/src/main/java/io/apitomy/axiom/app/rest/AssistantResourceImpl.java (requireConfigAssistant
at 638-643, toSessionInfo at 651-670, toTemplateBean at 681-700)
- Modify: common/api/src/main/resources/openapi.json (the AssistantSessionInfo schema at ~6773 and the
SessionTemplate schema at ~7641)
- Test: app/src/test/java/io/apitomy/axiom/app/assistant/SessionCapabilityEndpointTest.java
Interfaces:
- Consumes: SessionCapability and SessionTemplate.capabilities() from Task 1.
- Produces:
- AssistantSession.getCapabilities(): Set<SessionCapability>,
AssistantSession.hasCapability(SessionCapability): boolean and
AssistantSession.setCapabilities(Set<SessionCapability>).
- AssistantContextBuilder.createWorkingDirectory(Path sessionDir, Set<SessionCapability> capabilities),
which replaces the String templateId overload.
- REST: capabilities: string[] on AssistantSessionInfo and SessionTemplate.
- [ ] Step 1: Write the failing integration test
package io.apitomy.axiom.app.assistant;
import io.apitomy.axiom.app.assistant.runtime.InteractiveSessionDriver;
import io.apitomy.axiom.app.assistant.runtime.InteractiveSessionDriverFactory;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.empty;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Verifies that capabilities, not template IDs, drive config-assistant behaviour and are exposed over REST.
*/
@QuarkusTest
class SessionCapabilityEndpointTest {
@InjectMock
InteractiveSessionDriverFactory interactiveSessionDriverFactory;
private final List<String> created = new ArrayList<>();
@AfterEach
void cleanUp() {
created.forEach(id -> given().delete("/api/v1/assistant/sessions/{id}", id));
}
private String createSession(String templateId) throws IOException {
when(interactiveSessionDriverFactory.createDriver(any()))
.thenAnswer(i -> mock(InteractiveSessionDriver.class));
String id = given().contentType(ContentType.JSON)
.body("{\"templateId\":\"" + templateId + "\",\"name\":\"cap-test\"}")
.post("/api/v1/assistant/sessions")
.then().statusCode(200).extract().path("id");
created.add(id);
return id;
}
@Test
void configAssistantSessionExposesCapabilitiesAndItems() throws IOException {
String id = createSession("axiom-config-assistant");
given().get("/api/v1/assistant/sessions/{id}", id)
.then().statusCode(200)
.body("capabilities", containsInAnyOrder("config-items", "validation-feedback"));
given().get("/api/v1/assistant/sessions/{id}/items", id).then().statusCode(200);
}
@Test
void generalSessionHasNoCapabilitiesAndNoItems() throws IOException {
String id = createSession("general-assistant");
given().get("/api/v1/assistant/sessions/{id}", id)
.then().statusCode(200).body("capabilities", empty());
given().get("/api/v1/assistant/sessions/{id}/items", id).then().statusCode(400);
}
@Test
void templatesExposeCapabilities() {
given().get("/api/v1/assistant/templates/{id}", "axiom-config-assistant")
.then().statusCode(200)
.body("capabilities", containsInAnyOrder("config-items", "validation-feedback"));
}
}
- [ ] Step 2: Run the test and confirm it fails
Run: mvn -q -pl app -am test -Dtest=SessionCapabilityEndpointTest -Dsurefire.failIfNoSpecifiedTests=false
Expected: FAIL. The capabilities path is null in the responses.
- [ ] Step 3: Update the OpenAPI contract
In openapi.json, add this property to the AssistantSessionInfo schema's properties, after
allowAll, and to the SessionTemplate schema's properties, after builtIn:
"capabilities": {
"description": "Behaviours enabled by the session's template, for example config-items, validation-feedback or composition. Read-only; user-defined templates have none.",
"readOnly": true,
"type": "array",
"items": {
"type": "string"
}
},
Watch the commas: allowAll is currently the last property in AssistantSessionInfo, so add a comma after
its closing brace and none after the new property. Then regenerate:
mvn -q -pl common/api install -DskipTests
- [ ] Step 4: Carry capabilities on
AssistantSession
In AssistantSession.java:
1. Add import java.util.Collections; and import java.util.EnumSet;. Set is already imported.
2. Add the field and methods:
private volatile Set<SessionCapability> capabilities = Collections.emptySet();
/**
* Returns the capabilities of the template this session was created from.
*
* @return an unmodifiable set, empty if none
*/
public Set<SessionCapability> getCapabilities() {
return capabilities;
}
/**
* Sets the capabilities of this session. Called once by the session manager before the session starts.
*
* @param capabilities the template's capabilities
*/
public void setCapabilities(Set<SessionCapability> capabilities) {
this.capabilities = capabilities.isEmpty()
? Collections.emptySet()
: Collections.unmodifiableSet(EnumSet.copyOf(capabilities));
}
/**
* Returns whether this session has a capability.
*
* @param capability the capability
* @return true if the session's template declares it
*/
public boolean hasCapability(SessionCapability capability) {
return capabilities.contains(capability);
}
- [ ] Step 5: Use capabilities in
AssistantContextBuilderandAssistantSessionManager
AssistantContextBuilder.createWorkingDirectory: change the second parameter to
java.util.Set<SessionCapability> capabilities, update its Javadoc @param, and replace the
"axiom-config-assistant".equals(templateId) condition with
capabilities.contains(SessionCapability.CONFIG_ITEMS).
AssistantSessionManager.createSession:
- Change both contextBuilder.createWorkingDirectory(sessionDir, templateId) calls to
contextBuilder.createWorkingDirectory(sessionDir, template.capabilities()).
- Directly after AssistantSession session = new AssistantSession(...); (before sessionRef.set(session)),
add session.setCapabilities(template.capabilities());.
- Replace
if ("axiom-config-assistant".equals(templateId)) {
session.addListener(createValidationListener(session));
}
with
if (session.hasCapability(SessionCapability.VALIDATION_FEEDBACK)) {
session.addListener(createValidationListener(session));
}
- [ ] Step 6: Use capabilities in
AssistantResourceImpl
Replace the body of requireConfigAssistant, keeping the method name and message:
private void requireConfigAssistant(AssistantSession session) {
if (!session.hasCapability(SessionCapability.CONFIG_ITEMS)) {
throw new WebApplicationException(
"Items are only available for Configuration Assistant sessions", 400);
}
}
In toSessionInfo, add
info.setCapabilities(session.getCapabilities().stream().map(SessionCapability::jsonName).sorted().toList());.
In toTemplateBean, add
bean.setCapabilities(template.capabilities().stream().map(SessionCapability::jsonName).sorted().toList());.
Import io.apitomy.axiom.app.assistant.SessionCapability.
- [ ] Step 7: Confirm no template-ID checks remain in main code
Run: git grep -n '"axiom-config-assistant"' -- app/src/main/java
Expected: only SessionTemplateService.java (the BUILT_IN_FILES entry "axiom-config-assistant.json").
- [ ] Step 8: Run the new and existing assistant tests
Run:
mvn -q -pl app -am test -Dtest='SessionCapabilityEndpointTest,SessionCapabilityTest,Assistant*Test,InteractiveSessionDriverFactoryTest' -Dsurefire.failIfNoSpecifiedTests=false
Expected: PASS. If an existing test fails, fix the production code, not the test.
- [ ] Step 9: Commit
git add common/api/src/main/resources/openapi.json app/src/main/java/io/apitomy/axiom/app/assistant \
app/src/main/java/io/apitomy/axiom/app/rest/AssistantResourceImpl.java \
app/src/test/java/io/apitomy/axiom/app/assistant/SessionCapabilityEndpointTest.java
git commit -m "refactor(assistant): drive config-assistant behaviour by template capabilities"
Task 3: UI uses capabilities
Files:
- Create: ui/src/config/capabilities.ts
- Create: ui/src/config/capabilities.test.ts
- Modify: ui/src/config/api.ts (the AssistantSessionInfo interface at ~1087 and the session template
interface; find it with git grep -n "builtIn" ui/src/config/api.ts)
- Modify: ui/src/pages/AssistantSessionPage.tsx:261
- Modify: ui/src/pages/SessionTemplatesPage.tsx:153
Interfaces:
- Consumes: the REST capabilities: string[] from Task 2.
- Produces: hasCapability(target: { capabilities?: string[] } | null | undefined, capability: SessionCapability): boolean
and type SessionCapability = "config-items" | "validation-feedback" | "composition".
- [ ] Step 1: Write the failing test
import { describe, expect, it } from "vitest";
import { hasCapability } from "./capabilities";
describe("hasCapability", () => {
it("is true when the capability is listed", () => {
expect(hasCapability({ capabilities: ["config-items"] }, "config-items")).toBe(true);
});
it("is false when the capability is absent", () => {
expect(hasCapability({ capabilities: ["composition"] }, "config-items")).toBe(false);
});
it("is false when capabilities are missing or the target is null", () => {
expect(hasCapability({}, "config-items")).toBe(false);
expect(hasCapability(null, "config-items")).toBe(false);
expect(hasCapability(undefined, "composition")).toBe(false);
});
});
- [ ] Step 2: Run the test and confirm it fails
Run (in ui/): npx vitest run src/config/capabilities.test.ts
Expected: FAIL. The module cannot be resolved.
- [ ] Step 3: Implement the helper
/** Behaviours a session template enables. Mirrors the backend `SessionCapability` JSON names. */
export type SessionCapability = "config-items" | "validation-feedback" | "composition";
/**
* Returns whether a session or template declares a capability.
*
* @param target a session info or template object, possibly missing
* @param capability the capability to look for
* @returns true if `target.capabilities` contains `capability`
*/
export function hasCapability(
target: { capabilities?: string[] } | null | undefined,
capability: SessionCapability,
): boolean {
return target?.capabilities?.includes(capability) ?? false;
}
-
[ ] Step 4: Use the helper in the pages
-
In
api.ts, addcapabilities?: string[];toAssistantSessionInfoand to the session template interface. - In
AssistantSessionPage.tsx, change line 261 toconst isConfigAssistant = hasCapability(session, "config-items");and importhasCapabilityfrom../config/capabilities. -
In
SessionTemplatesPage.tsx, change line 153's condition to!hasCapability(t, "config-items") && (. Templates with capabilities can't be cloned, because user templates cannot carry capabilities. Add the import. -
[ ] Step 5: Type-check and run the UI tests
Run (in ui/): npx tsc -b --noEmit && npx vitest run
Expected: no type errors, and all tests pass (the 38 existing tests plus 3 new ones).
- [ ] Step 6: Commit
git add ui/src/config/capabilities.ts ui/src/config/capabilities.test.ts ui/src/config/api.ts \
ui/src/pages/AssistantSessionPage.tsx ui/src/pages/SessionTemplatesPage.tsx
git commit -m "refactor(ui): use session capabilities instead of template IDs"
Task 4: Per-session MCP token
Files:
- Modify: app/src/main/java/io/apitomy/axiom/app/assistant/AssistantSession.java
- Modify: app/src/main/java/io/apitomy/axiom/app/assistant/AssistantSessionManager.java
(resolveMcpServers at ~865-893, createSession)
- Modify: app/src/main/java/io/apitomy/axiom/app/rest/AssistantResourceImpl.java
(validateAssistantItem at ~428)
- Modify: app/src/main/resources/templates/axiom-assistant-mcp/server.js (axiomApi at lines 20-36)
- Modify: common/api/src/main/resources/openapi.json (the description of validateAssistantItem, plus a
401 response)
- Modify: app/src/test/java/io/apitomy/axiom/app/assistant/AssistantValidateEndpointTest.java
- Test: app/src/test/java/io/apitomy/axiom/app/assistant/SessionTokenTest.java
Interfaces:
- Consumes: Task 2's session setup.
- Produces:
- AssistantSession.getMcpToken(): String, a 32-byte URL-safe Base64 random value without padding.
- AssistantSession.verifyMcpToken(String presented): boolean, which compares in constant time.
- The environment variable AXIOM_SESSION_TOKEN on the axiom (@axiom-assistant) MCP server.
- POST /assistant/sessions/{id}/validate returns 401 unless X-Axiom-Session-Token matches.
- [ ] Step 1: Write the failing test
package io.apitomy.axiom.app.assistant;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.apitomy.axiom.app.assistant.runtime.InteractiveSessionDriver;
import io.apitomy.axiom.app.assistant.runtime.InteractiveSessionDriverFactory;
import io.quarkus.test.InjectMock;
import io.quarkus.test.junit.QuarkusTest;
import io.restassured.http.ContentType;
import jakarta.inject.Inject;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Verifies the per-session MCP token: generation, hand-off to the MCP server, and enforcement.
*/
@QuarkusTest
class SessionTokenTest {
private static final String BODY = "{\"type\": \"tool\", \"item\": {\"name\": \"t\"}}";
@InjectMock
InteractiveSessionDriverFactory interactiveSessionDriverFactory;
@Inject
AssistantSessionManager manager;
private String sessionId;
@BeforeEach
void create() {
when(interactiveSessionDriverFactory.createDriver(any()))
.thenAnswer(i -> mock(InteractiveSessionDriver.class));
sessionId = given().contentType(ContentType.JSON)
.body("{\"templateId\":\"axiom-config-assistant\",\"name\":\"token-test\"}")
.post("/api/v1/assistant/sessions").then().statusCode(200).extract().path("id");
}
@AfterEach
void delete() {
given().delete("/api/v1/assistant/sessions/{id}", sessionId);
}
@Test
void tokenIsPassedToAssistantMcpServer() throws IOException {
AssistantSession session = manager.getSession(sessionId);
JsonNode config = new ObjectMapper().readTree(
Files.readString(session.getSessionDirectory().resolve("mcp-config.json")));
assertEquals(session.getMcpToken(),
config.path("mcpServers").path("axiom").path("env").path("AXIOM_SESSION_TOKEN").asText());
}
@Test
void tokensAreUniqueAndVerified() {
AssistantSession session = manager.getSession(sessionId);
assertTrue(session.getMcpToken().length() >= 43);
assertTrue(session.verifyMcpToken(session.getMcpToken()));
assertFalse(session.verifyMcpToken("wrong"));
assertFalse(session.verifyMcpToken(null));
String other = given().contentType(ContentType.JSON)
.body("{\"templateId\":\"axiom-config-assistant\",\"name\":\"token-test-2\"}")
.post("/api/v1/assistant/sessions").then().statusCode(200).extract().path("id");
try {
assertNotEquals(session.getMcpToken(), manager.getSession(other).getMcpToken());
} finally {
given().delete("/api/v1/assistant/sessions/{id}", other);
}
}
@Test
void validateRequiresToken() {
given().contentType(ContentType.JSON).body(BODY)
.post("/api/v1/assistant/sessions/{id}/validate", sessionId).then().statusCode(401);
given().contentType(ContentType.JSON).header("X-Axiom-Session-Token", "wrong").body(BODY)
.post("/api/v1/assistant/sessions/{id}/validate", sessionId).then().statusCode(401);
given().contentType(ContentType.JSON)
.header("X-Axiom-Session-Token", manager.getSession(sessionId).getMcpToken()).body(BODY)
.post("/api/v1/assistant/sessions/{id}/validate", sessionId).then().statusCode(200);
}
}
- [ ] Step 2: Run the test and confirm it fails
Run: mvn -q -pl app -am test -Dtest=SessionTokenTest -Dsurefire.failIfNoSpecifiedTests=false
Expected: a compilation failure, because getMcpToken does not exist.
- [ ] Step 3: Generate and verify the token in
AssistantSession
Add these imports: java.security.MessageDigest, java.security.SecureRandom, java.util.Base64 and
java.nio.charset.StandardCharsets. Then add:
private static final SecureRandom RANDOM = new SecureRandom();
private volatile String mcpToken = newToken();
/**
* Generates a new random session token (32 bytes, URL-safe Base64 without padding).
*
* @return the token
*/
static String newToken() {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
/**
* Sets this session's MCP token. Called once by the session manager, which generates the token before the
* session is constructed so it can be written into the MCP configuration.
*
* @param token the token
*/
void setMcpToken(String token) {
this.mcpToken = token;
}
/**
* Returns the secret token this session's MCP servers present to Axiom REST.
*
* @return the token
*/
public String getMcpToken() {
return mcpToken;
}
/**
* Checks a presented token against this session's token in constant time.
*
* @param presented the value of the {@code X-Axiom-Session-Token} header, possibly null
* @return true if it matches
*/
public boolean verifyMcpToken(String presented) {
return presented != null && MessageDigest.isEqual(
mcpToken.getBytes(StandardCharsets.UTF_8), presented.getBytes(StandardCharsets.UTF_8));
}
- [ ] Step 4: Pass the token to the MCP server
resolveMcpServers runs before the AssistantSession object exists, so the manager generates the token
first and hands it to the session with setMcpToken. In AssistantSessionManager.createSession:
- Directly after String sessionId = UUID.randomUUID().toString();, add
String mcpToken = AssistantSession.newToken();.
- Add a String mcpToken parameter to resolveMcpServers after sessionId, and pass it in.
- Directly after session.setCapabilities(...), add session.setMcpToken(mcpToken);.
- In resolveMcpServers, inside the @axiom-assistant branch, add
env.put("AXIOM_SESSION_TOKEN", mcpToken); after the AXIOM_ASSISTANT_SESSION_ID line.
- [ ] Step 5: Enforce the token on the validate endpoint
In AssistantResourceImpl:
1. Add the field @jakarta.ws.rs.core.Context jakarta.ws.rs.core.HttpHeaders httpHeaders;.
2. Add the constant static final String SESSION_TOKEN_HEADER = "X-Axiom-Session-Token";.
3. At the start of validateAssistantItem, before the type/item check, add:
AssistantSession session = requireSession(sessionId);
if (!session.verifyMcpToken(httpHeaders.getHeaderString(SESSION_TOKEN_HEADER))) {
throw new WebApplicationException("Missing or invalid " + SESSION_TOKEN_HEADER, 401);
}
requireSession returns 404 for an unknown session, so the existing "no-such-session" test still gets
404.
In openapi.json, append this sentence to the validateAssistantItem description: Called by the
session's MCP server; requires the X-Axiom-Session-Token header with the session's token. Add a response
"401": {"description": "Missing or invalid session token"} next to 404. The header is read through
HttpHeaders and is not a generated parameter, so the interface signature does not change. Regenerate
with mvn -q -pl common/api install -DskipTests.
- [ ] Step 6: Send the header from the MCP server
In server.js, add the constant const AXIOM_SESSION_TOKEN = process.env.AXIOM_SESSION_TOKEN || "";
next to the other process.env constants. In axiomApi, after opts is built, add:
Check the syntax with node --check app/src/main/resources/templates/axiom-assistant-mcp/server.js.
- [ ] Step 7: Update the existing validate test to send the token
In AssistantValidateEndpointTest, add .header("X-Axiom-Session-Token", token(<id>)) to the three
validate calls at lines ~100, ~139 and ~160. Leave the "no-such-session" call at ~189 unchanged, since
it expects 404. Add the helper:
- [ ] Step 8: Run the tests and confirm they pass
Run:
mvn -q -pl app -am test -Dtest='SessionTokenTest,AssistantValidateEndpointTest,SessionCapabilityEndpointTest' -Dsurefire.failIfNoSpecifiedTests=false
Expected: PASS.
- [ ] Step 9: Commit
git add common/api/src/main/resources/openapi.json app/src/main/java/io/apitomy/axiom/app/assistant \
app/src/main/java/io/apitomy/axiom/app/rest/AssistantResourceImpl.java \
app/src/main/resources/templates/axiom-assistant-mcp/server.js \
app/src/test/java/io/apitomy/axiom/app/assistant/SessionTokenTest.java \
app/src/test/java/io/apitomy/axiom/app/assistant/AssistantValidateEndpointTest.java
git commit -m "feat(assistant): authenticate MCP calls with a per-session token"
Task 5: Persisted conversation history journal
This task persists the history. Rebuilding a live session from the journal after a restart needs the composition entity, so it belongs to Phase 2. This task provides the writer, the loader and their wiring.
Files:
- Create: app/src/main/java/io/apitomy/axiom/app/assistant/HistoryJournal.java
- Modify: app/src/main/java/io/apitomy/axiom/app/assistant/AssistantSessionManager.java (createSession)
- Test: app/src/test/java/io/apitomy/axiom/app/assistant/HistoryJournalTest.java
- Test: extend app/src/test/java/io/apitomy/axiom/app/assistant/SessionTokenTest.java with one wiring test
Interfaces:
- Produces:
- final class HistoryJournal implements Consumer<SseEvent>, with the constructor
HistoryJournal(Path file, ObjectMapper mapper).
- void accept(SseEvent), which appends one line and never throws (I/O errors are logged).
- static List<SseEvent> load(Path file, ObjectMapper mapper) throws IOException. It returns the
events after the last conversation_reset (including that reset), matching in-memory history, and skips
malformed lines.
- static final String FILE_NAME = "history.jsonl".
- [ ] Step 1: Write the failing unit test
package io.apitomy.axiom.app.assistant;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.apitomy.axiom.app.assistant.AssistantEventParser.SseEvent;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Verifies that the history journal round-trips events and mirrors in-memory reset semantics.
*/
class HistoryJournalTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static SseEvent event(String type, String text) {
ObjectNode data = MAPPER.createObjectNode();
data.put("text", text);
return new SseEvent(type, data);
}
@Test
void roundTripsEventsInOrder(@TempDir Path dir) throws IOException {
Path file = dir.resolve(HistoryJournal.FILE_NAME);
HistoryJournal journal = new HistoryJournal(file, MAPPER);
journal.accept(event("user_message", "hi"));
journal.accept(event("assistant_text", "hello\nthere"));
List<SseEvent> loaded = HistoryJournal.load(file, MAPPER);
assertEquals(2, loaded.size());
assertEquals("assistant_text", loaded.get(1).type());
assertEquals("hello\nthere", loaded.get(1).data().path("text").asText());
}
@Test
void loadStartsAtLastConversationReset(@TempDir Path dir) throws IOException {
Path file = dir.resolve(HistoryJournal.FILE_NAME);
HistoryJournal journal = new HistoryJournal(file, MAPPER);
journal.accept(event("user_message", "old"));
journal.accept(event("conversation_reset", ""));
journal.accept(event("user_message", "new"));
List<SseEvent> loaded = HistoryJournal.load(file, MAPPER);
assertEquals(List.of("conversation_reset", "user_message"),
loaded.stream().map(SseEvent::type).toList());
}
@Test
void skipsMalformedLinesAndMissingFile(@TempDir Path dir) throws IOException {
Path file = dir.resolve(HistoryJournal.FILE_NAME);
assertTrue(HistoryJournal.load(file, MAPPER).isEmpty());
new HistoryJournal(file, MAPPER).accept(event("user_message", "ok"));
Files.writeString(file, "{not json\n", StandardOpenOption.APPEND);
assertEquals(1, HistoryJournal.load(file, MAPPER).size());
}
@Test
void writeFailureDoesNotThrow(@TempDir Path dir) {
HistoryJournal journal = new HistoryJournal(dir.resolve("missing-dir/history.jsonl"), MAPPER);
assertDoesNotThrow(() -> journal.accept(event("user_message", "x")));
}
}
- [ ] Step 2: Run the test and confirm it fails
Run: mvn -q -pl app -am test -Dtest=HistoryJournalTest -Dsurefire.failIfNoSpecifiedTests=false
Expected: a compilation failure, because HistoryJournal does not exist.
- [ ] Step 3: Implement the journal
package io.apitomy.axiom.app.assistant;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.apitomy.axiom.app.assistant.AssistantEventParser.SseEvent;
import org.jboss.logging.Logger;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
/**
* Appends every event of an assistant session to a JSON Lines file so that the conversation can be shown again
* after a server restart. Each line is {@code {"type": ..., "data": ...}}.
*/
public final class HistoryJournal implements Consumer<SseEvent> {
/** File name of the journal inside the session directory. */
public static final String FILE_NAME = "history.jsonl";
private static final Logger LOG = Logger.getLogger(HistoryJournal.class);
private final Path file;
private final ObjectMapper mapper;
/**
* Creates a journal writing to {@code file}.
*
* @param file the journal file; its parent directory must exist
* @param mapper the JSON mapper
*/
public HistoryJournal(Path file, ObjectMapper mapper) {
this.file = file;
this.mapper = mapper;
}
/**
* Appends one event. Failures are logged and never propagated, so a full disk cannot break a session.
*
* @param event the event to record
*/
@Override
public synchronized void accept(SseEvent event) {
ObjectNode line = mapper.createObjectNode();
line.put("type", event.type());
line.set("data", event.data());
try {
Files.writeString(file, line.toString() + "\n", StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException e) {
LOG.warnf("Failed to append to history journal %s: %s", file, e.getMessage());
}
}
/**
* Loads a journal. Like the in-memory history, a {@code conversation_reset} discards earlier events.
* Malformed lines are skipped.
*
* @param file the journal file
* @param mapper the JSON mapper
* @return the events, oldest first; empty if the file does not exist
* @throws IOException if the file exists but cannot be read
*/
public static List<SseEvent> load(Path file, ObjectMapper mapper) throws IOException {
List<SseEvent> events = new ArrayList<>();
if (!Files.exists(file)) {
return events;
}
for (String text : Files.readAllLines(file, StandardCharsets.UTF_8)) {
if (text.isBlank()) {
continue;
}
JsonNode line;
try {
line = mapper.readTree(text);
} catch (IOException e) {
LOG.debugf("Skipping malformed history line in %s", file);
continue;
}
String type = line.path("type").asText("");
if (type.isEmpty()) {
continue;
}
if ("conversation_reset".equals(type)) {
events.clear();
}
events.add(new SseEvent(type, line.path("data")));
}
return events;
}
}
- [ ] Step 4: Run the unit test and confirm it passes
Run: mvn -q -pl app -am test -Dtest=HistoryJournalTest -Dsurefire.failIfNoSpecifiedTests=false
Expected: PASS (4 tests).
- [ ] Step 5: Attach the journal to every session
In AssistantSessionManager.createSession, directly after sessionRef.set(session); and before
session.start();, add:
// Persist every event (including the welcome message and startup events) to history.jsonl
session.addListener(new HistoryJournal(sessionDir.resolve(HistoryJournal.FILE_NAME), objectMapper));
- [ ] Step 6: Add a wiring test
Append to SessionTokenTest. It already creates a config-assistant session with a mocked driver:
@Test
void sessionEventsAreJournaled() throws IOException {
AssistantSession session = manager.getSession(sessionId);
com.fasterxml.jackson.databind.node.ObjectNode data = new ObjectMapper().createObjectNode();
data.put("text", "journal me");
session.addEvent(new AssistantEventParser.SseEvent("assistant_text", data));
java.util.List<AssistantEventParser.SseEvent> loaded = HistoryJournal.load(
session.getSessionDirectory().resolve(HistoryJournal.FILE_NAME), new ObjectMapper());
assertEquals("journal me", loaded.get(loaded.size() - 1).data().path("text").asText());
}
- [ ] Step 7: Run the assistant test suite
Run:
mvn -q -pl app -am test -Dtest='HistoryJournalTest,SessionTokenTest,Assistant*Test,SessionCapability*Test' -Dsurefire.failIfNoSpecifiedTests=false
Expected: PASS.
- [ ] Step 8: Commit
git add app/src/main/java/io/apitomy/axiom/app/assistant/HistoryJournal.java \
app/src/main/java/io/apitomy/axiom/app/assistant/AssistantSessionManager.java \
app/src/test/java/io/apitomy/axiom/app/assistant/HistoryJournalTest.java \
app/src/test/java/io/apitomy/axiom/app/assistant/SessionTokenTest.java
git commit -m "feat(assistant): journal session history to history.jsonl"
Task 6: Full verification
Files: none. This task only verifies.
- [ ] Step 1: Full backend build and tests (no clean)
Run: mvn package
Expected: BUILD SUCCESS. The test count is the 847 baseline plus the new tests, with 0 failures.
- [ ] Step 2: UI checks
Run (in ui/): npx tsc -b --noEmit && npx vitest run
Expected: no type errors, and all tests pass.
- [ ] Step 3: Confirm nothing depends on the template ID
Run: git grep -n 'axiom-config-assistant' -- app/src/main ui/src
Expected: only the template file name and its templateId field, SessionTemplateService.BUILT_IN_FILES,
and any UI default-template selection, which is fine. There should be no behaviour checks.
- [ ] Step 4: Report
Report which commands were run and their results. State that a manual check in the browser (opening a Configuration Assistant session and applying an item) was not done unless it actually was.