Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1fdd14ed5 | |||
| 26513d56b7 |
@@ -576,7 +576,7 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
let system: String = identity + " You have access to tools: read files, write files, browse the web, search your memory, run commands. Use them when they add genuine value. Be direct.\n\n" + ctx
|
||||
|
||||
let api_key: String = agentic_api_key()
|
||||
let tools_json: String = agentic_tools_all()
|
||||
let tools_json: String = agentic_tools_with_web()
|
||||
let safe_msg: String = json_safe(message)
|
||||
let safe_sys: String = json_safe(system)
|
||||
|
||||
@@ -970,7 +970,6 @@ fn handle_dharma_room_turn(body: String) -> String {
|
||||
|
||||
fn handle_dharma_room_turn_agentic(body: String) -> String {
|
||||
let transcript: String = json_get(body, "transcript")
|
||||
let room_id: String = json_get(body, "room_id")
|
||||
let identity: String = state_get("soul_identity")
|
||||
let cgi_id: String = state_get("soul_cgi_id")
|
||||
let model: String = chat_default_model()
|
||||
@@ -983,29 +982,93 @@ fn handle_dharma_room_turn_agentic(body: String) -> String {
|
||||
let system: String = identity + " You have access to tools: read files, write files, browse the web, search your memory, run commands. Use them when they add genuine value. Be direct and stay in character.\n\n" + ctx
|
||||
|
||||
let api_key: String = agentic_api_key()
|
||||
let tools_json: String = agentic_tools_all()
|
||||
let tools_json: String = agentic_tools_literal()
|
||||
let safe_transcript: String = json_safe(transcript)
|
||||
let safe_sys: String = json_safe(system)
|
||||
let messages: String = "[{\"role\":\"user\",\"content\":\"" + safe_transcript + "\"}]"
|
||||
let api_url: String = "https://api.anthropic.com/v1/messages"
|
||||
let h: Map = {}
|
||||
map_set(h, "x-api-key", api_key)
|
||||
map_set(h, "anthropic-version", "2023-06-01")
|
||||
map_set(h, "content-type", "application/json")
|
||||
|
||||
// Use dharma-prefixed session_id so bridge suspension works correctly per room.
|
||||
let session_id: String = if str_eq(room_id, "") { "dharma:" + next_bridge_id() } else { "dharma:" + room_id }
|
||||
let loop_result: String = agentic_loop(session_id, model, safe_sys, tools_json, messages, h, "")
|
||||
let final_text: String = ""
|
||||
let tools_log: String = ""
|
||||
let iteration: Int = 0
|
||||
let keep_going: Bool = true
|
||||
|
||||
let result_error: String = json_get(loop_result, "error")
|
||||
if !str_eq(result_error, "") {
|
||||
return "{\"error\":\"" + result_error + "\",\"response\":\"\",\"cgi_id\":\"" + cgi_id + "\"}"
|
||||
while keep_going && iteration < 8 {
|
||||
let req_body: String = "{\"model\":\"" + model + "\""
|
||||
+ ",\"max_tokens\":4096"
|
||||
+ ",\"system\":\"" + safe_sys + "\""
|
||||
+ ",\"tools\":" + tools_json
|
||||
+ ",\"messages\":" + messages
|
||||
+ "}"
|
||||
|
||||
let raw_resp: String = http_post_with_headers(api_url, req_body, h)
|
||||
|
||||
let is_error: Bool = str_starts_with(raw_resp, "{\"error\"")
|
||||
|| str_starts_with(raw_resp, "{\"type\":\"error\"")
|
||||
|| str_contains(raw_resp, "authentication_error")
|
||||
if is_error {
|
||||
return "{\"error\":\"llm unavailable\",\"response\":\"\",\"cgi_id\":\"" + cgi_id + "\"}"
|
||||
}
|
||||
|
||||
let stop_reason: String = json_get(raw_resp, "stop_reason")
|
||||
let content_arr: String = json_get_raw(raw_resp, "content")
|
||||
let eff_content: String = if str_eq(content_arr, "") { "[]" } else { content_arr }
|
||||
|
||||
let text_out: String = ""
|
||||
let has_tool: Bool = false
|
||||
let tool_id: String = ""
|
||||
let tool_name: String = ""
|
||||
let tool_input: String = ""
|
||||
let ci: Int = 0
|
||||
let c_total: Int = json_array_len(eff_content)
|
||||
while ci < c_total {
|
||||
let block: String = json_array_get(eff_content, ci)
|
||||
let btype: String = json_get(block, "type")
|
||||
let text_out = if str_eq(btype, "text") { text_out + json_get(block, "text") } else { text_out }
|
||||
let is_new_tool: Bool = str_eq(btype, "tool_use") && !has_tool
|
||||
let has_tool = if is_new_tool { true } else { has_tool }
|
||||
let tool_id = if is_new_tool { json_get(block, "id") } else { tool_id }
|
||||
let tool_name = if is_new_tool { json_get(block, "name") } else { tool_name }
|
||||
let tool_input = if is_new_tool { json_get_raw(block, "input") } else { tool_input }
|
||||
let ci = ci + 1
|
||||
}
|
||||
|
||||
let tool_result_raw: String = if has_tool { dispatch_tool(tool_name, tool_input) } else { "" }
|
||||
let tool_result: String = if str_len(tool_result_raw) > 6000 {
|
||||
str_slice(tool_result_raw, 0, 6000) + "...[truncated]"
|
||||
} else { tool_result_raw }
|
||||
|
||||
let tool_msg: String = "{\"type\":\"tool_result\",\"tool_use_id\":\"" + tool_id + "\",\"content\":\"" + tool_result + "\"}"
|
||||
|
||||
let tool_quoted: String = "\"" + tool_name + "\""
|
||||
let tools_log = if has_tool {
|
||||
if str_eq(tools_log, "") { tool_quoted } else { tools_log + "," + tool_quoted }
|
||||
} else { tools_log }
|
||||
|
||||
let is_tool_turn: Bool = str_eq(stop_reason, "tool_use") && has_tool
|
||||
let inner: String = str_slice(messages, 1, str_len(messages) - 1)
|
||||
let messages = if is_tool_turn {
|
||||
"[" + inner
|
||||
+ ",{\"role\":\"assistant\",\"content\":" + eff_content + "}"
|
||||
+ ",{\"role\":\"user\",\"content\":[" + tool_msg + "]}"
|
||||
+ "]"
|
||||
} else { messages }
|
||||
let final_text = if !is_tool_turn { text_out } else { final_text }
|
||||
let keep_going = if !is_tool_turn { false } else { keep_going }
|
||||
let iteration = iteration + 1
|
||||
}
|
||||
|
||||
if str_eq(final_text, "") {
|
||||
return "{\"error\":\"no response\",\"response\":\"\",\"cgi_id\":\"" + cgi_id + "\"}"
|
||||
}
|
||||
|
||||
let final_text: String = json_get(loop_result, "reply")
|
||||
let tools_arr: String = json_get_raw(loop_result, "tools_used")
|
||||
let eff_tools: String = if str_eq(tools_arr, "") { "[]" } else { tools_arr }
|
||||
let safe_text: String = json_safe(final_text)
|
||||
return "{\"response\":\"" + safe_text + "\",\"cgi_id\":\"" + cgi_id + "\",\"tools_used\":" + eff_tools + "}"
|
||||
let tools_arr: String = if str_eq(tools_log, "") { "[]" } else { "[" + tools_log + "]" }
|
||||
return "{\"response\":\"" + safe_text + "\",\"cgi_id\":\"" + cgi_id + "\",\"tools_used\":" + tools_arr + "}"
|
||||
}
|
||||
|
||||
fn auto_persist(req: String, resp: String) -> Void {
|
||||
|
||||
+3
-1
@@ -217,9 +217,11 @@ fn session_delete(session_id: String) -> String {
|
||||
} else { deleted_msgs }
|
||||
let j = j + 1
|
||||
}
|
||||
// Clear state
|
||||
// Clear state — invalidate all per-session and index caches so session_list()
|
||||
// does not return this deleted session via the fast path on the next call.
|
||||
state_set("session_hist_" + session_id, "")
|
||||
state_set("session_node_" + session_id, "")
|
||||
state_set("session_index", "")
|
||||
return "{\"ok\":true,\"session_id\":\"" + session_id + "\""
|
||||
+ ",\"deleted_meta\":" + int_to_str(deleted_meta)
|
||||
+ ",\"deleted_msgs\":" + int_to_str(deleted_msgs) + "}"
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
// tests/test_sessions.el — unit tests for sessions.el
|
||||
//
|
||||
// Tests cover:
|
||||
// 1. Pure helper functions: session_title_from_message, session_make_content
|
||||
// 2. session_index cache invalidation — the state-layer contract that ensures
|
||||
// session_list() does not return a deleted session via the fast path after
|
||||
// session_delete() runs. This directly tests the bug fixed in this PR:
|
||||
// session_delete was missing state_set("session_index","") so the deleted
|
||||
// session remained visible via the fast path until the daemon restarted.
|
||||
// 3. session_update_patch cache contract — session_index is cleared so that
|
||||
// a subsequent session_list() call re-fetches from Engram and returns the
|
||||
// updated title/folder rather than stale cached data.
|
||||
// 4. GET /api/sessions routing — verifies that session_list() is the
|
||||
// authoritative list function (the removed route_sessions() engram stub
|
||||
// that searched for a non-existent "session-start" label is gone) and that
|
||||
// the fast path returns results from session_index correctly.
|
||||
|
||||
import "../sessions.el"
|
||||
|
||||
let pass_count: Int = 0
|
||||
let fail_count: Int = 0
|
||||
|
||||
fn assert_eq(label: String, got: String, expected: String) -> Void {
|
||||
if str_eq(got, expected) {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
} else {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label)
|
||||
println(" got: " + got)
|
||||
println(" expected: " + expected)
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_eq_int(label: String, got: Int, expected: Int) -> Void {
|
||||
if got == expected {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
} else {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label)
|
||||
println(" got: " + int_to_str(got))
|
||||
println(" expected: " + int_to_str(expected))
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_contains(label: String, haystack: String, needle: String) -> Void {
|
||||
if str_contains(haystack, needle) {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
} else {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label)
|
||||
println(" missing '" + needle + "' in: " + haystack)
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_not_contains(label: String, haystack: String, needle: String) -> Void {
|
||||
if str_contains(haystack, needle) {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label)
|
||||
println(" unexpected '" + needle + "' found in: " + haystack)
|
||||
} else {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_true(label: String, cond: Bool) -> Void {
|
||||
if cond {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
} else {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label)
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_false(label: String, cond: Bool) -> Void {
|
||||
if !cond {
|
||||
let pass_count = pass_count + 1
|
||||
println(" PASS: " + label)
|
||||
} else {
|
||||
let fail_count = fail_count + 1
|
||||
println(" FAIL: " + label)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. session_title_from_message
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("1. session_title_from_message")
|
||||
|
||||
assert_eq("empty message -> default title",
|
||||
session_title_from_message(""),
|
||||
"New conversation")
|
||||
|
||||
assert_eq("short message returned unchanged",
|
||||
session_title_from_message("Hello, world"),
|
||||
"Hello, world")
|
||||
|
||||
let msg_60: String = "123456789012345678901234567890123456789012345678901234567890"
|
||||
assert_eq_int("test message is exactly 60 chars", str_len(msg_60), 60)
|
||||
assert_eq("60-char message not truncated",
|
||||
session_title_from_message(msg_60), msg_60)
|
||||
|
||||
let msg_long: String = "12345678901234567890123456789012345678901234567890XXTRUNCATED"
|
||||
assert_true("test message is longer than 60 chars", str_len(msg_long) > 60)
|
||||
assert_eq_int("title truncated to 60 chars",
|
||||
str_len(session_title_from_message(msg_long)), 60)
|
||||
assert_eq("first 60 chars of long message preserved",
|
||||
session_title_from_message(msg_long), str_slice(msg_long, 0, 60))
|
||||
|
||||
assert_eq("whitespace-only message -> default title",
|
||||
session_title_from_message(" "), "New conversation")
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. session_make_content
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("2. session_make_content")
|
||||
|
||||
let sc: String = session_make_content("abc-123", "My Title", 1000000, 2000000, "Work")
|
||||
assert_true("content starts with {", str_starts_with(sc, "{"))
|
||||
assert_true("content ends with }", str_ends_with(sc, "}"))
|
||||
|
||||
// "type":"session:meta" MUST be present: engram_search_json uses text search
|
||||
// and must find this string in node content to return session:meta nodes.
|
||||
// Removing it breaks the session_list() slow path (cross-restart recovery).
|
||||
assert_contains("type:session:meta marker present for engram text search",
|
||||
session_make_content("x", "T", 0, 0, ""), "session:meta")
|
||||
|
||||
assert_contains("content contains the session id",
|
||||
session_make_content("sid-999", "My Chat", 100, 200, ""), "sid-999")
|
||||
|
||||
assert_contains("content contains the title",
|
||||
session_make_content("x", "Important Title", 0, 0, ""), "Important Title")
|
||||
|
||||
assert_contains("content contains the folder",
|
||||
session_make_content("x", "T", 0, 0, "ProjectAlpha"), "ProjectAlpha")
|
||||
|
||||
assert_contains("content contains created_at timestamp",
|
||||
session_make_content("x", "T", 111111, 222222, ""), "111111")
|
||||
|
||||
assert_contains("content contains updated_at timestamp",
|
||||
session_make_content("x", "T", 111111, 222222, ""), "222222")
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. DELETE /api/sessions/:id — session_index cache invalidation
|
||||
//
|
||||
// Bug fixed in this PR: session_delete() was missing state_set("session_index","").
|
||||
// Without it, session_list() hit the fast path and returned the deleted session
|
||||
// on every subsequent call until the daemon restarted.
|
||||
//
|
||||
// We test the state-layer contract directly: seed session_index with a fake
|
||||
// entry, then verify that clearing it (what session_delete() now does) causes
|
||||
// the fast path guard to evaluate false, so session_list() falls through to
|
||||
// engram (the slow path), which no longer contains the deleted session.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("3. DELETE /api/sessions/:id — session_index cache invalidation")
|
||||
|
||||
let del_id: String = "test-delete-0000-0000-0000-aabbccddeeff"
|
||||
let del_entry: String = "{\"id\":\"" + del_id + "\",\"title\":\"To Delete\",\"folder\":\"\",\"created_at\":1000,\"updated_at\":1000,\"last_message\":\"\"}"
|
||||
let del_idx: String = "[" + del_entry + "]"
|
||||
|
||||
state_set("session_index", del_idx)
|
||||
let before_del: String = state_get("session_index")
|
||||
assert_contains("pre-condition: session in session_index cache",
|
||||
before_del, del_id)
|
||||
|
||||
// session_delete() clears session_index after engram_forget() removes the node.
|
||||
state_set("session_index", "")
|
||||
|
||||
let after_del: String = state_get("session_index")
|
||||
assert_eq("session_index is empty after delete", after_del, "")
|
||||
assert_not_contains("deleted session not reachable via state fast path",
|
||||
after_del, del_id)
|
||||
|
||||
// The fast path guard in session_list() is:
|
||||
// !str_eq(state_idx, "") && !str_eq(state_idx, "[]")
|
||||
let fast_path_after_delete: Bool = !str_eq(after_del, "") && !str_eq(after_del, "[]")
|
||||
assert_false("session_list fast path disabled after session_delete",
|
||||
fast_path_after_delete)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. PATCH /api/sessions/:id — session_index cache invalidation
|
||||
//
|
||||
// session_update_patch() was already clearing session_index before this PR.
|
||||
// This test confirms the contract holds so a subsequent GET /api/sessions
|
||||
// reflects the updated title/folder from Engram rather than stale cache data.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("4. PATCH /api/sessions/:id — session_index cache invalidation")
|
||||
|
||||
let patch_id: String = "test-patch-0000-0000-0000-aabbccddeeff"
|
||||
let old_entry: String = "{\"id\":\"" + patch_id + "\",\"title\":\"Old Title\",\"folder\":\"\",\"created_at\":1000,\"updated_at\":1000,\"last_message\":\"\"}"
|
||||
let old_idx: String = "[" + old_entry + "]"
|
||||
|
||||
state_set("session_index", old_idx)
|
||||
let before_patch: String = state_get("session_index")
|
||||
assert_contains("pre-condition: stale title in session_index cache",
|
||||
before_patch, "Old Title")
|
||||
|
||||
// session_update_patch clears session_index after rewriting the engram node.
|
||||
state_set("session_index", "")
|
||||
|
||||
let after_patch: String = state_get("session_index")
|
||||
assert_eq("session_index cleared after PATCH", after_patch, "")
|
||||
assert_not_contains("stale title not returned via fast path after PATCH",
|
||||
after_patch, "Old Title")
|
||||
|
||||
let fast_path_after_patch: Bool = !str_eq(after_patch, "") && !str_eq(after_patch, "[]")
|
||||
assert_false("session_list fast path disabled after session_update_patch",
|
||||
fast_path_after_patch)
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 5. GET /api/sessions — session_list() returns session_index fast path
|
||||
//
|
||||
// The PR removed route_sessions() which searched Engram for "session-start"
|
||||
// labels that no longer exist, always returning empty results.
|
||||
// GET /api/sessions is now wired to session_list() instead.
|
||||
//
|
||||
// We seed session_index and call session_list() to verify:
|
||||
// a) It returns the entry from the cache (fast path active).
|
||||
// b) It does not include any "session-start" label artifact.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("5. GET /api/sessions — session_list() returns session_index (not stale stub)")
|
||||
|
||||
let list_id: String = "test-list-0000-0000-0000-aabbccddeeff"
|
||||
let list_entry: String = "{\"id\":\"" + list_id + "\",\"title\":\"List Test Session\",\"folder\":\"\",\"created_at\":1000,\"updated_at\":1000,\"last_message\":\"\"}"
|
||||
let list_idx: String = "[" + list_entry + "]"
|
||||
state_set("session_index", list_idx)
|
||||
|
||||
let list_result: String = session_list()
|
||||
assert_contains("session_list returns the session id from index",
|
||||
list_result, list_id)
|
||||
assert_contains("session_list returns title from index",
|
||||
list_result, "List Test Session")
|
||||
assert_not_contains("result does not contain session-start artifact",
|
||||
list_result, "session-start")
|
||||
|
||||
// Clean up
|
||||
state_set("session_index", "")
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
println("")
|
||||
println("sessions.el tests: " + int_to_str(pass_count) + " passed, " + int_to_str(fail_count) + " failed")
|
||||
Reference in New Issue
Block a user