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
116 changes: 80 additions & 36 deletions crates/app-logic/src/session_extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,18 @@ pub use ssh_manager_core::outgoing::ServerScope;

/// Registered through `Wiring`. All methods have no-op defaults so an
/// extension only implements what it needs.
#[async_trait::async_trait]
pub trait SessionExtension: Send + Sync {
/// Text appended to the session system context of this server. The core
/// redacts it and places it in its own fenced section after the core
/// paragraphs; it is asked again on every turn.
fn context_section(&self, _scope: &ServerScope) -> Option<String> {
/// paragraphs; it is asked again on every turn. `language` is the prompt
/// language of the session (the one the section heading uses). Async, so
/// the extension can await reads from its own data store.
async fn context_section(
&self,
_scope: &ServerScope,
_language: crate::system_prompt::PromptLanguage,
) -> Option<String> {
None
}

Expand Down Expand Up @@ -137,7 +144,7 @@ pub fn extension_heading(language: crate::system_prompt::PromptLanguage) -> &'st
/// fence). Returns whether a section was added — the caller treats that like
/// notes: untrusted content has entered the context (Spec 0039, section 5).
/// Without an extension, or with an empty section, `parts` stays untouched.
pub fn append_extension_section(
pub async fn append_extension_section(
parts: &mut SystemContextParts,
extension: Option<&dyn SessionExtension>,
scope: Option<&ServerScope>,
Expand All @@ -146,7 +153,7 @@ pub fn append_extension_section(
let (Some(ext), Some(scope)) = (extension, scope) else {
return false;
};
let Some(text) = ext.context_section(scope) else {
let Some(text) = ext.context_section(scope, parts.language).await else {
return false;
};
if text.trim().is_empty() {
Expand Down Expand Up @@ -245,8 +252,13 @@ mod tests {
secrets: Vec<String>,
}

#[async_trait::async_trait]
impl SessionExtension for TestExtension {
fn context_section(&self, scope: &ServerScope) -> Option<String> {
async fn context_section(
&self,
scope: &ServerScope,
_language: PromptLanguage,
) -> Option<String> {
(scope.server_id == self.server)
.then(|| self.section.clone())
.flatten()
Expand Down Expand Up @@ -278,8 +290,8 @@ mod tests {
}
}

#[test]
fn test_section_is_fenced_after_core_text_in_both_languages() {
#[tokio::test]
async fn test_section_is_fenced_after_core_text_in_both_languages() {
let id = ServerId::new();
let ext = TestExtension {
server: id,
Expand All @@ -292,7 +304,8 @@ mod tests {
] {
let mut p = parts(language);
let redactor = DefaultOutputRedactor::new();
let added = append_extension_section(&mut p, Some(&ext), Some(&scope(id)), &redactor);
let added =
append_extension_section(&mut p, Some(&ext), Some(&scope(id)), &redactor).await;
assert!(added);
let text = p.assemble();
assert!(text.starts_with("BASE"));
Expand All @@ -303,19 +316,14 @@ mod tests {
}
}

#[test]
fn test_without_extension_or_section_the_context_is_unchanged() {
#[tokio::test]
async fn test_without_extension_or_section_the_context_is_unchanged() {
let id = ServerId::new();
let redactor = DefaultOutputRedactor::new();
let before = parts(PromptLanguage::En).assemble();

let mut none = parts(PromptLanguage::En);
assert!(!append_extension_section(
&mut none,
None,
Some(&scope(id)),
&redactor
));
assert!(!append_extension_section(&mut none, None, Some(&scope(id)), &redactor).await);
assert_eq!(none.assemble(), before);

// another server: the extension returns no section
Expand All @@ -325,12 +333,15 @@ mod tests {
secrets: Vec::new(),
};
let mut other = parts(PromptLanguage::En);
assert!(!append_extension_section(
&mut other,
Some(&ext),
Some(&scope(ServerId::new())),
&redactor
));
assert!(
!append_extension_section(
&mut other,
Some(&ext),
Some(&scope(ServerId::new())),
&redactor
)
.await
);
assert_eq!(other.assemble(), before);

// whitespace-only section: no empty section either
Expand All @@ -341,17 +352,53 @@ mod tests {
};
let mut p = parts(PromptLanguage::De);
let before_de = p.assemble();
assert!(!append_extension_section(
&mut p,
Some(&blank),
Some(&scope(id)),
&redactor
));
assert!(!append_extension_section(&mut p, Some(&blank), Some(&scope(id)), &redactor).await);
assert_eq!(p.assemble(), before_de);
}

#[test]
fn test_section_is_redacted_before_it_is_fenced() {
/// Answers per language and only after an actual `.await` point.
struct LangExtension {
gate: tokio::sync::RwLock<()>,
}

#[async_trait::async_trait]
impl SessionExtension for LangExtension {
async fn context_section(
&self,
_scope: &ServerScope,
language: PromptLanguage,
) -> Option<String> {
let _guard = self.gate.read().await;
match language {
PromptLanguage::De => Some("deutscher Text".to_string()),
_ => Some("english text".to_string()),
}
}
}

#[tokio::test]
async fn test_section_receives_the_prompt_language_and_may_await() {
let ext = LangExtension {
gate: tokio::sync::RwLock::new(()),
};
let id = ServerId::new();
let redactor = DefaultOutputRedactor::new();
for (language, present, absent) in [
(PromptLanguage::De, "deutscher Text", "english text"),
(PromptLanguage::En, "english text", "deutscher Text"),
] {
let mut p = parts(language);
assert!(
append_extension_section(&mut p, Some(&ext), Some(&scope(id)), &redactor).await
);
let text = p.assemble();
assert!(text.contains(present), "{text}");
assert!(!text.contains(absent), "{text}");
}
}

#[tokio::test]
async fn test_section_is_redacted_before_it_is_fenced() {
let id = ServerId::new();
let ext = TestExtension {
server: id,
Expand All @@ -361,12 +408,9 @@ mod tests {
let secrets = known_secrets(Some(&ext), Some(&scope(id)));
let redactor = redactor_with_secrets(None, &secrets);
let mut p = parts(PromptLanguage::En);
assert!(append_extension_section(
&mut p,
Some(&ext),
Some(&scope(id)),
redactor.as_ref()
));
assert!(
append_extension_section(&mut p, Some(&ext), Some(&scope(id)), redactor.as_ref()).await
);
let text = p.assemble();
assert!(!text.contains(SECRET));
assert!(text.contains(REDACTED_PLACEHOLDER));
Expand Down
3 changes: 2 additions & 1 deletion crates/app-shell/src/commands/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,8 @@ pub(crate) async fn send_chat_message_with_attachments_impl<R: tauri::Runtime>(
Some(extension),
scope.as_ref(),
&session.redactor,
);
)
.await;
notes_present || present
} else {
notes_present
Expand Down
3 changes: 2 additions & 1 deletion crates/app-shell/src/commands/connect.rs
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,8 @@ pub(crate) async fn connect_session<R: tauri::Runtime>(
state.session_extension.as_deref(),
extension_scope.as_ref(),
redactor.as_ref(),
);
)
.await;
let notes_present = notes_present || extension_present;
let system_context = system_context_parts.assemble();
// Issue #323: die `suggest_command`-Beschreibung folgt derselben Sprache
Expand Down
8 changes: 8 additions & 0 deletions docs/adr/0136-session-extension-point.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,11 @@ Status: accepted (issue #463, Spec 0038)
delete. A group delete reports the group and each descendant group (they
are removed by `ON DELETE CASCADE`); servers in the group are only
unassigned, not deleted, so they get no callback.
- **Async context hook with the prompt language** (#526): `context_section`
is an `#[async_trait]` method (like `OutgoingTransform::prepare_outgoing`)
and receives the session's `PromptLanguage`, the value that picks the
heading. Extension tables sit behind an async database handle, so a
synchronous hook would need `block_in_place` or a duplicate cache.
`append_extension_section` is async and both callers await it; order and
redact-then-fence are unchanged. `known_secrets` and the removal callbacks
stay synchronous.
5 changes: 4 additions & 1 deletion docs/specs/0038-repository-app-shell-ci.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ des lokalen Pseudo-Servers, Gruppenkette von der Wurzel her, Tags):
Englisch) und in einem eigenen Fence (Spec 0039). Der Text gilt wie eine
Notiz als nicht vertrauenswürdig: er wird erst redigiert, dann gefenct,
und setzt wie Notizen das Merkmal „nicht vertrauenswürdiger Inhalt
eingelesen“. Der Abschnitt wird bei jeder Nutzernachricht neu erfragt.
eingelesen“. Der Abschnitt wird bei jeder Nutzernachricht neu erfragt. Die Erweiterung
erhält dabei die Prompt-Sprache der Sitzung (dieselbe, die die Überschrift
bestimmt) und darf asynchron antworten, etwa nach dem Lesen aus ihrem
eigenen Datenspeicher.
Liefert die Erweiterung nichts (oder nur Leerraum) oder gibt es keine
Erweiterung, ist der Kontext unverändert, ohne leeren Abschnitt.
- **Bekannte Secrets:** Zeichenfolgen, die der Redactor dieses Servers
Expand Down
Loading