Skip to content

Commit 5673560

Browse files
committed
Add support for returning context with Intent results
Includes docs & specs, types, tests, ref implementation and conformance tests)
1 parent 1a43a03 commit 5673560

25 files changed

Lines changed: 586 additions & 38 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
1717
* Added `ContextMetadata` and `AppProvidableContextMetadata` types, replacing the optional `OriginatingAppMetadata` feature with required metadata support on `ContextHandler` and `IntentHandler` callbacks. Desktop Agents MUST provide `source` and `timestamp` metadata, and MUST forward app-supplied `traceId`, `signature` and `custom` fields, supporting observability and security use cases. ([#1728](https://github.com/finos/FDC3/pull/1728))
1818
* Added `version-check` script and integrated it into the `syncpack` script and Publish To NPM workflow to prevent version mismatches causing incorrect npm dist-tags. ([#1864](https://github.com/finos/FDC3/pull/1864))
1919
* Added Channel Interface Compliance and PrivateChannel Interface Compliance subsections to the Desktop Agent API Standard Compliance section in the API spec, enumerating MUST/SHOULD/MAY requirements for all `Channel` and `PrivateChannel` functions including `getCurrentContextWithMetadata`. ([#1728](https://github.com/finos/FDC3/pull/1728))
20+
* Added `getResultMetadata()` to `IntentResolution` to allow the raising app to retrieve `ContextMetadata` for an intent result. Updated `IntentHandler` to allow returning `ContextWithMetadata` so that handlers can include app-provided metadata (e.g. `traceId`, `signature`) alongside a context result. The Desktop Agent merges app-provided metadata with its own generated fields before delivering to the raising app. For `Channel` or `void` results, only Desktop Agent generated metadata is returned. ([#1728](https://github.com/finos/FDC3/pull/1728))
2021

2122
### Changed
2223

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,36 @@
1-
import { AppIdentifier, IntentResolution, IntentResult } from '@finos/fdc3-standard';
1+
import { AppIdentifier, ContextMetadata, IntentResolution, IntentResult } from '@finos/fdc3-standard';
22
import { Messaging } from '../Messaging.js';
33

44
export class DefaultIntentResolution implements IntentResolution {
55
readonly messaging: Messaging;
66
readonly source: AppIdentifier;
77
readonly intent: string;
88
readonly result: Promise<IntentResult>;
9+
readonly resultMetadata: Promise<ContextMetadata>;
910

10-
constructor(messaging: Messaging, result: Promise<IntentResult>, source: AppIdentifier, intent: string) {
11+
constructor(
12+
messaging: Messaging,
13+
result: Promise<IntentResult>,
14+
resultMetadata: Promise<ContextMetadata>,
15+
source: AppIdentifier,
16+
intent: string
17+
) {
1118
this.messaging = messaging;
1219
this.result = result;
20+
this.resultMetadata = resultMetadata;
1321
this.source = source;
1422
this.intent = intent;
1523

1624
//bind all functions to allow destructuring
1725
this.getResult = this.getResult.bind(this);
26+
this.getResultMetadata = this.getResultMetadata.bind(this);
1827
}
1928

2029
getResult(): Promise<IntentResult> {
2130
return this.result;
2231
}
32+
33+
getResultMetadata(): Promise<ContextMetadata> {
34+
return this.resultMetadata;
35+
}
2336
}

packages/fdc3-agent-proxy/src/intents/DefaultIntentSupport.ts

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
AppIntent,
33
AppIdentifier,
4+
ContextMetadata,
45
IntentResolution,
56
IntentHandler,
67
Listener,
@@ -56,6 +57,17 @@ const convertIntentResult = async (
5657
}
5758
};
5859

60+
const extractResultMetadata = ({ payload }: RaiseIntentResultResponse, source: AppIdentifier): ContextMetadata => {
61+
const rm = payload.resultMetadata;
62+
return {
63+
source,
64+
timestamp: rm?.timestamp ?? new Date(),
65+
traceId: rm?.traceId ?? '',
66+
...(rm?.signature !== undefined && { signature: rm.signature }),
67+
...(rm?.custom !== undefined && { custom: rm.custom }),
68+
};
69+
};
70+
5971
export class DefaultIntentSupport implements IntentSupport {
6072
readonly messaging: Messaging;
6173
readonly intentResolver: IntentResolver;
@@ -123,13 +135,25 @@ export class DefaultIntentSupport implements IntentSupport {
123135
}
124136
}
125137

126-
private async createResultPromise(request: RaiseIntentRequest | RaiseIntentForContextRequest): Promise<IntentResult> {
127-
const rp = await this.messaging.waitFor<RaiseIntentResultResponse>(
128-
m => m.type == 'raiseIntentResultResponse' && m.meta.requestUuid == request.meta.requestUuid
129-
);
138+
private createResultPromises(
139+
request: RaiseIntentRequest | RaiseIntentForContextRequest,
140+
source: AppIdentifier
141+
): { result: Promise<IntentResult>; resultMetadata: Promise<ContextMetadata> } {
142+
let resolveMetadata!: (m: ContextMetadata) => void;
143+
const resultMetadata = new Promise<ContextMetadata>(resolve => {
144+
resolveMetadata = resolve;
145+
});
146+
147+
const result = this.messaging
148+
.waitFor<RaiseIntentResultResponse>(
149+
m => m.type == 'raiseIntentResultResponse' && m.meta.requestUuid == request.meta.requestUuid
150+
)
151+
.then(async rp => {
152+
resolveMetadata(extractResultMetadata(rp, source));
153+
return convertIntentResult(rp, this.messaging, this.messageExchangeTimeout);
154+
});
130155

131-
const ir = await convertIntentResult(rp, this.messaging, this.messageExchangeTimeout);
132-
return ir;
156+
return { result, resultMetadata };
133157
}
134158

135159
async raiseIntent(
@@ -154,7 +178,6 @@ export class DefaultIntentSupport implements IntentSupport {
154178
meta,
155179
};
156180

157-
const resultPromise = this.createResultPromise(request);
158181
const response = await this.messaging.exchange<RaiseIntentResponse>(
159182
request,
160183
'raiseIntentResponse',
@@ -170,19 +193,29 @@ export class DefaultIntentSupport implements IntentSupport {
170193

171194
if (response.payload.appIntent) {
172195
// Needs further resolution, we need to invoke the resolver
173-
const result: IntentResolutionChoice | void = await this.intentResolver.chooseIntent(
196+
const choice: IntentResolutionChoice | void = await this.intentResolver.chooseIntent(
174197
[response.payload.appIntent],
175198
context
176199
);
177-
if (result) {
178-
return this.raiseIntent(intent, context, result.appId, metadata);
200+
if (choice) {
201+
return this.raiseIntent(intent, context, choice.appId, metadata);
179202
} else {
180203
throw new Error(ResolveError.UserCancelled);
181204
}
182205
} else {
183206
// Was resolved
184207
const details = response.payload.intentResolution!;
185-
return new DefaultIntentResolution(this.messaging, resultPromise, details.source, details.intent);
208+
const { result: resolvedResult, resultMetadata: resolvedMetadata } = this.createResultPromises(
209+
request,
210+
details.source
211+
);
212+
return new DefaultIntentResolution(
213+
this.messaging,
214+
resolvedResult,
215+
resolvedMetadata,
216+
details.source,
217+
details.intent
218+
);
186219
}
187220
}
188221

@@ -206,7 +239,6 @@ export class DefaultIntentSupport implements IntentSupport {
206239
meta,
207240
};
208241

209-
const resultPromise = this.createResultPromise(request);
210242
const response = await this.messaging.exchange<RaiseIntentForContextResponse>(
211243
request,
212244
'raiseIntentForContextResponse',
@@ -222,19 +254,29 @@ export class DefaultIntentSupport implements IntentSupport {
222254

223255
if (response.payload.appIntents) {
224256
// Needs further resolution, we need to invoke the resolver
225-
const result: IntentResolutionChoice | void = await this.intentResolver.chooseIntent(
257+
const choice: IntentResolutionChoice | void = await this.intentResolver.chooseIntent(
226258
response.payload.appIntents,
227259
context
228260
);
229-
if (result) {
230-
return this.raiseIntent(result.intent, context, result.appId, metadata);
261+
if (choice) {
262+
return this.raiseIntent(choice.intent, context, choice.appId, metadata);
231263
} else {
232264
throw new Error(ResolveError.UserCancelled);
233265
}
234266
} else {
235267
// Was resolved
236268
const details = response.payload.intentResolution!;
237-
return new DefaultIntentResolution(this.messaging, resultPromise, details.source, details.intent);
269+
const { result: resolvedResult, resultMetadata: resolvedMetadata } = this.createResultPromises(
270+
request,
271+
details.source
272+
);
273+
return new DefaultIntentResolution(
274+
this.messaging,
275+
resolvedResult,
276+
resolvedMetadata,
277+
details.source,
278+
details.intent
279+
);
238280
}
239281
}
240282

packages/fdc3-agent-proxy/src/listeners/DefaultIntentListener.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { IntentHandler, IntentResult, AppIdentifier } from '@finos/fdc3-standard';
1+
import {
2+
IntentHandler,
3+
IntentResult,
4+
AppIdentifier,
5+
AppProvidableContextMetadata,
6+
ContextWithMetadata,
7+
} from '@finos/fdc3-standard';
28
import { Context } from '@finos/fdc3-context';
39
import { Messaging } from '../Messaging.js';
410
import { AbstractListener } from './AbstractListener.js';
@@ -44,7 +50,11 @@ export class DefaultIntentListener extends AbstractListener<IntentHandler, AddIn
4450
this.handleIntentResult(done, m);
4551
}
4652

47-
private intentResultRequestMessage(ir: IntentResult, m: IntentEvent): IntentResultRequest {
53+
private intentResultRequestMessage(
54+
ir: IntentResult,
55+
appMetadata: AppProvidableContextMetadata | undefined,
56+
m: IntentEvent
57+
): IntentResultRequest {
4858
const out: IntentResultRequest = {
4959
type: 'intentResultRequest',
5060
meta: {
@@ -55,25 +65,25 @@ export class DefaultIntentListener extends AbstractListener<IntentHandler, AddIn
5565
intentResult: convertIntentResult(ir),
5666
intentEventUuid: m.meta.eventUuid,
5767
raiseIntentRequestUuid: m.payload.raiseIntentRequestUuid,
68+
...(appMetadata !== undefined && { metadata: appMetadata }),
5869
},
5970
};
6071

6172
return out;
6273
}
6374

64-
private handleIntentResult(done: Promise<IntentResult> | void, m: IntentEvent) {
75+
private handleIntentResult(done: Promise<IntentResult | ContextWithMetadata> | void, m: IntentEvent) {
6576
if (done == null) {
66-
// send an empty intent result response
6777
return this.messaging.exchange<IntentResultResponse>(
68-
this.intentResultRequestMessage(undefined, m),
78+
this.intentResultRequestMessage(undefined, undefined, m),
6979
'intentResultResponse',
7080
this.messageExchangeTimeout
7181
);
7282
} else {
73-
// respond after promise completes
74-
return done.then(ir => {
83+
return done.then(raw => {
84+
const { result, appMetadata } = unwrapIntentResult(raw);
7585
return this.messaging.exchange<IntentResultResponse>(
76-
this.intentResultRequestMessage(ir, m),
86+
this.intentResultRequestMessage(result, appMetadata, m),
7787
'intentResultResponse',
7888
this.messageExchangeTimeout
7989
);
@@ -82,6 +92,18 @@ export class DefaultIntentListener extends AbstractListener<IntentHandler, AddIn
8292
}
8393
}
8494

95+
function unwrapIntentResult(raw: IntentResult | ContextWithMetadata): {
96+
result: IntentResult;
97+
appMetadata: AppProvidableContextMetadata | undefined;
98+
} {
99+
if (raw && typeof raw === 'object' && 'context' in raw && 'metadata' in raw && !('type' in raw) && !('id' in raw)) {
100+
// It's a ContextWithMetadata — unwrap it
101+
const cwm = raw as ContextWithMetadata;
102+
return { result: cwm.context, appMetadata: cwm.metadata };
103+
}
104+
return { result: raw as IntentResult, appMetadata: undefined };
105+
}
106+
85107
function convertIntentResult(intentResult: IntentResult): IntentResultRequest['payload']['intentResult'] {
86108
if (!intentResult) {
87109
//consider any falsy result to be void...

packages/fdc3-agent-proxy/test/features/intent-results.feature

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,35 @@ Feature: Intents Can Return Different Results
126126
And messaging will have posts
127127
| payload.intent | payload.context.type | payload.context.id.ticker | matches_type |
128128
| OrderFood | fdc3.instrument | AAPL | raiseIntentRequest |
129+
130+
Scenario: getResultMetadata returns DA-generated metadata for a context result
131+
Given Raise Intent returns a context of "{instrumentContext}"
132+
When I call "{api}" with "raiseIntent" with parameters "OrderFood" and "{instrumentContext}"
133+
And I call "{result}" with "getResultMetadata"
134+
Then "{result}" is an object with the following contents
135+
| source.appId | source.instanceId |
136+
| some-app | abc123 |
137+
138+
Scenario: getResultMetadata returns merged metadata when ContextWithMetadata is returned
139+
Given Raise Intent returns a context of "{instrumentContext}" with traceId "my-trace-123" and signature "sig-abc"
140+
When I call "{api}" with "raiseIntent" with parameters "OrderFood" and "{instrumentContext}"
141+
And I call "{result}" with "getResultMetadata"
142+
Then "{result}" is an object with the following contents
143+
| source.appId | source.instanceId | traceId | signature |
144+
| some-app | abc123 | my-trace-123 | sig-abc |
145+
146+
Scenario: getResultMetadata returns DA-generated metadata for a channel result
147+
Given Raise Intent returns a private channel
148+
When I call "{api}" with "raiseIntent" with parameters "OrderFood" and "{instrumentContext}"
149+
And I call "{result}" with "getResultMetadata"
150+
Then "{result}" is an object with the following contents
151+
| source.appId | source.instanceId |
152+
| some-app | abc123 |
153+
154+
Scenario: getResultMetadata returns DA-generated metadata for a void result
155+
Given Raise Intent returns no result
156+
When I call "{api}" with "raiseIntent" with parameters "OrderFood" and "{instrumentContext}"
157+
And I call "{result}" with "getResultMetadata"
158+
Then "{result}" is an object with the following contents
159+
| source.appId | source.instanceId |
160+
| some-app | abc123 |

packages/fdc3-agent-proxy/test/step-definitions/intents.steps.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,22 @@ Given('Raise Intent returns a context of {string}', (world: CustomWorld, result:
8282
});
8383
});
8484

85+
Given(
86+
'Raise Intent returns a context of {string} with traceId {string} and signature {string}',
87+
(world: CustomWorld, result: string, traceId: string, signature: string) => {
88+
world.messaging?.setIntentResult({
89+
context: handleResolve(result, world),
90+
resultMetadata: {
91+
source: { appId: 'some-app', instanceId: 'abc123' },
92+
timestamp: new Date('2024-01-01T00:00:00Z'),
93+
traceId,
94+
signature,
95+
custom: { priority: 'high' },
96+
},
97+
});
98+
}
99+
);
100+
85101
Given('Raise Intent will throw a {string} error', (world: CustomWorld, error: ResolveError) => {
86102
world.messaging?.setIntentResult({
87103
error,

packages/fdc3-agent-proxy/test/support/TestMessaging.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AppIdentifier, ResolveError } from '@finos/fdc3-standard';
1+
import { AppIdentifier, ContextMetadata, ResolveError } from '@finos/fdc3-standard';
22
import { Context } from '@finos/fdc3-context';
33
import { v4 as uuidv4 } from 'uuid';
44
import { AbstractMessaging } from '../../src/messaging/AbstractMessaging.js';
@@ -45,6 +45,7 @@ export interface PossibleIntentResult {
4545
channel?: Channel;
4646
error?: ResolveError;
4747
timeout?: boolean;
48+
resultMetadata?: ContextMetadata;
4849
}
4950

5051
function matchStringOrUndefined(expected: string | undefined, actual: string | undefined) {

packages/fdc3-agent-proxy/test/support/responses/RaiseIntent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export class RaiseIntent implements AutomaticResponse {
121121
meta: createResponseMeta(intentRequest.meta),
122122
payload: {
123123
intentResult: result,
124+
...(result.resultMetadata && { resultMetadata: result.resultMetadata }),
124125
},
125126
type: 'raiseIntentResultResponse',
126127
};

packages/fdc3-agent-proxy/test/support/responses/RaiseIntentForContext.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ export class RaiseIntentForContext implements AutomaticResponse {
130130
},
131131
payload: {
132132
intentResult: result,
133+
...(result.resultMetadata && { resultMetadata: result.resultMetadata }),
133134
},
134135
type: 'raiseIntentResultResponse',
135136
};

0 commit comments

Comments
 (0)